wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -0,0 +1,15 @@
# VexLens Compiled Models
This directory contains compiled model stubs for the VexLens EF Core DbContext.
To regenerate after schema or model changes, run:
```bash
dotnet ef dbcontext optimize \
--project src/VexLens/StellaOps.VexLens.Persistence/ \
--output-dir EfCore/CompiledModels \
--namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels
```
After regeneration, ensure that `VexLensDbContextAssemblyAttributes.cs` remains
excluded from compilation via the `.csproj` `<Compile Remove>` directive.

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for VexLensDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.VexLensDbContext))]
public partial class VexLensDbContextModel : RuntimeModel
{
private static VexLensDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new VexLensDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for VexLensDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class VexLensDbContextModel
{
partial void Initialize()
{
// Stub: when a real compiled model is generated, entity types will be registered here.
// The runtime factory will fall back to reflection-based model building for all schemas
// until this stub is replaced with a full compiled model.
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.VexLens.Persistence.EfCore.Models;
namespace StellaOps.VexLens.Persistence.EfCore.Context;
public partial class VexLensDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// -- FK: consensus_projections.previous_projection_id -> consensus_projections.id --
modelBuilder.Entity<ConsensusProjectionEntity>(entity =>
{
entity.HasOne(e => e.PreviousProjection)
.WithMany()
.HasForeignKey(e => e.PreviousProjectionId)
.OnDelete(DeleteBehavior.SetNull);
});
// -- FK: consensus_inputs.projection_id -> consensus_projections.id (ON DELETE CASCADE) --
modelBuilder.Entity<ConsensusInputEntity>(entity =>
{
entity.HasOne(e => e.Projection)
.WithMany(p => p.Inputs)
.HasForeignKey(e => e.ProjectionId)
.OnDelete(DeleteBehavior.Cascade);
});
// -- FK: consensus_conflicts.projection_id -> consensus_projections.id (ON DELETE CASCADE) --
modelBuilder.Entity<ConsensusConflictEntity>(entity =>
{
entity.HasOne(e => e.Projection)
.WithMany(p => p.Conflicts)
.HasForeignKey(e => e.ProjectionId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.VexLens.Persistence.EfCore.Models;
namespace StellaOps.VexLens.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the VexLens module.
/// Maps to the vexlens PostgreSQL schema: consensus_projections,
/// consensus_inputs, and consensus_conflicts tables.
/// </summary>
public partial class VexLensDbContext : DbContext
{
private readonly string _schemaName;
public VexLensDbContext(DbContextOptions<VexLensDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "vexlens"
: schemaName.Trim();
}
public virtual DbSet<ConsensusProjectionEntity> ConsensusProjections { get; set; }
public virtual DbSet<ConsensusInputEntity> ConsensusInputs { get; set; }
public virtual DbSet<ConsensusConflictEntity> ConsensusConflicts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
// -- consensus_projections ------------------------------------------------
modelBuilder.Entity<ConsensusProjectionEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("consensus_projections_pkey");
entity.ToTable("consensus_projections", schemaName);
// Indexes matching SQL migration
entity.HasIndex(e => e.VulnerabilityId, "idx_consensus_projections_vuln_id");
entity.HasIndex(e => e.ProductKey, "idx_consensus_projections_product_key");
entity.HasIndex(e => e.TenantId, "idx_consensus_projections_tenant_id")
.HasFilter("(tenant_id IS NOT NULL)");
entity.HasIndex(e => e.Status, "idx_consensus_projections_status");
entity.HasIndex(e => e.Outcome, "idx_consensus_projections_outcome");
entity.HasIndex(e => e.ComputedAt, "idx_consensus_projections_computed_at")
.IsDescending(true);
entity.HasIndex(e => e.StoredAt, "idx_consensus_projections_stored_at")
.IsDescending(true);
entity.HasIndex(e => e.ConfidenceScore, "idx_consensus_projections_confidence")
.IsDescending(true);
entity.HasIndex(e => e.StatusChanged, "idx_consensus_projections_status_changed")
.HasFilter("(status_changed = true)");
entity.HasIndex(e => new { e.VulnerabilityId, e.ProductKey, e.TenantId, e.ComputedAt },
"idx_consensus_projections_history")
.IsDescending(false, false, false, true);
// Column mappings
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
entity.Property(e => e.ProductKey).HasColumnName("product_key");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Justification).HasColumnName("justification");
entity.Property(e => e.ConfidenceScore).HasColumnName("confidence_score");
entity.Property(e => e.Outcome).HasColumnName("outcome");
entity.Property(e => e.StatementCount)
.HasDefaultValue(0)
.HasColumnName("statement_count");
entity.Property(e => e.ConflictCount)
.HasDefaultValue(0)
.HasColumnName("conflict_count");
entity.Property(e => e.RationaleSummary).HasColumnName("rationale_summary");
entity.Property(e => e.MergeTrace)
.HasColumnType("jsonb")
.HasColumnName("merge_trace");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
entity.Property(e => e.StoredAt)
.HasDefaultValueSql("now()")
.HasColumnName("stored_at");
entity.Property(e => e.PreviousProjectionId).HasColumnName("previous_projection_id");
entity.Property(e => e.StatusChanged)
.HasDefaultValue(false)
.HasColumnName("status_changed");
entity.Property(e => e.AttestationDigest).HasColumnName("attestation_digest");
entity.Property(e => e.InputHash).HasColumnName("input_hash");
});
// -- consensus_inputs ----------------------------------------------------
modelBuilder.Entity<ConsensusInputEntity>(entity =>
{
entity.HasKey(e => new { e.ProjectionId, e.StatementId })
.HasName("consensus_inputs_pkey");
entity.ToTable("consensus_inputs", schemaName);
entity.HasIndex(e => e.ProjectionId, "idx_consensus_inputs_projection");
entity.Property(e => e.ProjectionId).HasColumnName("projection_id");
entity.Property(e => e.StatementId).HasColumnName("statement_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Confidence).HasColumnName("confidence");
entity.Property(e => e.Weight).HasColumnName("weight");
});
// -- consensus_conflicts -------------------------------------------------
modelBuilder.Entity<ConsensusConflictEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("consensus_conflicts_pkey");
entity.ToTable("consensus_conflicts", schemaName);
entity.HasIndex(e => e.ProjectionId, "idx_consensus_conflicts_projection");
entity.HasIndex(e => e.Severity, "idx_consensus_conflicts_severity");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.ProjectionId).HasColumnName("projection_id");
entity.Property(e => e.Issuer1).HasColumnName("issuer1");
entity.Property(e => e.Issuer2).HasColumnName("issuer2");
entity.Property(e => e.Status1).HasColumnName("status1");
entity.Property(e => e.Status2).HasColumnName("status2");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.DetectedAt)
.HasDefaultValueSql("now()")
.HasColumnName("detected_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.VexLens.Persistence.EfCore.Context;
public sealed class VexLensDesignTimeDbContextFactory : IDesignTimeDbContextFactory<VexLensDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vexlens,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_VEXLENS_EF_CONNECTION";
public VexLensDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<VexLensDbContext>()
.UseNpgsql(connectionString)
.Options;
return new VexLensDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
public partial class ConsensusConflictEntity
{
/// <summary>
/// Navigation property: the projection this conflict belongs to.
/// </summary>
public virtual ConsensusProjectionEntity Projection { get; set; } = null!;
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for vexlens.consensus_conflicts table.
/// Detailed conflict records from consensus computation.
/// </summary>
public partial class ConsensusConflictEntity
{
public Guid Id { get; set; }
public Guid ProjectionId { get; set; }
public string Issuer1 { get; set; } = null!;
public string Issuer2 { get; set; } = null!;
public string Status1 { get; set; } = null!;
public string Status2 { get; set; } = null!;
public string Severity { get; set; } = null!;
public DateTime DetectedAt { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
public partial class ConsensusInputEntity
{
/// <summary>
/// Navigation property: the projection this input belongs to.
/// </summary>
public virtual ConsensusProjectionEntity Projection { get; set; } = null!;
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for vexlens.consensus_inputs table.
/// Tracks which VEX statements contributed to a consensus projection.
/// </summary>
public partial class ConsensusInputEntity
{
public Guid ProjectionId { get; set; }
public string StatementId { get; set; } = null!;
public string SourceId { get; set; } = null!;
public string Status { get; set; } = null!;
public double? Confidence { get; set; }
public double? Weight { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
public partial class ConsensusProjectionEntity
{
/// <summary>
/// Navigation property: self-referential link to previous projection.
/// </summary>
public virtual ConsensusProjectionEntity? PreviousProjection { get; set; }
/// <summary>
/// Navigation property: input statements that contributed to this projection.
/// </summary>
public virtual ICollection<ConsensusInputEntity> Inputs { get; set; } = new List<ConsensusInputEntity>();
/// <summary>
/// Navigation property: conflicts detected during this projection.
/// </summary>
public virtual ICollection<ConsensusConflictEntity> Conflicts { get; set; } = new List<ConsensusConflictEntity>();
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.VexLens.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for vexlens.consensus_projections table.
/// </summary>
public partial class ConsensusProjectionEntity
{
public Guid Id { get; set; }
public string VulnerabilityId { get; set; } = null!;
public string ProductKey { get; set; } = null!;
public string? TenantId { get; set; }
public string Status { get; set; } = null!;
public string? Justification { get; set; }
public double ConfidenceScore { get; set; }
public string Outcome { get; set; } = null!;
public int StatementCount { get; set; }
public int ConflictCount { get; set; }
public string? RationaleSummary { get; set; }
public string? MergeTrace { get; set; }
public DateTime ComputedAt { get; set; }
public DateTime StoredAt { get; set; }
public Guid? PreviousProjectionId { get; set; }
public bool StatusChanged { get; set; }
public string? AttestationDigest { get; set; }
public string? InputHash { get; set; }
}

View File

@@ -1,29 +1,32 @@
// SPDX-License-Identifier: BUSL-1.1
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
// (c) StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Persistence.EfCore.Models;
using StellaOps.VexLens.Storage;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.VexLens.Persistence.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-021)
/// PostgreSQL (EF Core) implementation of <see cref="IConsensusProjectionStore"/>.
/// Sprint: SPRINT_20260222_071_VexLens_dal_to_efcore (VEXLENS-EF-03)
/// </summary>
public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
{
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Persistence.PostgresConsensusProjectionStore");
private const int CommandTimeoutSeconds = 30;
private readonly NpgsqlDataSource _dataSource;
private readonly IConsensusEventEmitter? _eventEmitter;
@@ -76,65 +79,58 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
!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);
await using var dbContext = CreateDbContext(connection);
try
var entity = new ConsensusProjectionEntity
{
// 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);
Id = projectionId,
VulnerabilityId = result.VulnerabilityId,
ProductKey = result.ProductKey,
TenantId = options.TenantId,
Status = MapStatus(result.ConsensusStatus),
Justification = result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : null,
ConfidenceScore = result.ConfidenceScore,
Outcome = MapOutcome(result.Outcome),
StatementCount = result.Contributions.Count,
ConflictCount = result.Conflicts?.Count ?? 0,
RationaleSummary = result.Rationale.Summary,
ComputedAt = result.ComputedAt.UtcDateTime,
StoredAt = now.UtcDateTime,
PreviousProjectionId = previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : null,
StatusChanged = statusChanged
};
await cmd.ExecuteNonQueryAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
dbContext.ConsensusProjections.Add(entity);
await dbContext.SaveChangesAsync(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);
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);
_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
// Emit events if configured
if (options.EmitEvent && _eventEmitter is not null)
{
await transaction.RollbackAsync(cancellationToken);
throw;
await EmitEventsAsync(projection, previous, cancellationToken);
}
return projection;
}
/// <inheritdoc />
@@ -153,16 +149,13 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
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 dbContext = CreateDbContext(connection);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
return MapProjection(reader);
}
var entity = await dbContext.ConsensusProjections
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
return null;
return entity is not null ? MapToProjection(entity) : null;
}
/// <inheritdoc />
@@ -180,18 +173,17 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
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 dbContext = CreateDbContext(connection);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
return MapProjection(reader);
}
var entity = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& (tenantId == null ? e.TenantId == null : e.TenantId == tenantId))
.OrderByDescending(e => e.ComputedAt)
.FirstOrDefaultAsync(cancellationToken);
return null;
return entity is not null ? MapToProjection(entity) : null;
}
/// <inheritdoc />
@@ -203,32 +195,81 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
using var activity = ActivitySource.StartActivity("ListAsync");
var (sql, countSql, parameters) = BuildListQuery(query);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var dbContext = CreateDbContext(connection);
IQueryable<ConsensusProjectionEntity> dbQuery = dbContext.ConsensusProjections.AsNoTracking();
// Apply filters
if (query.TenantId is not null)
dbQuery = dbQuery.Where(e => e.TenantId == query.TenantId);
if (query.VulnerabilityId is not null)
dbQuery = dbQuery.Where(e => e.VulnerabilityId == query.VulnerabilityId);
if (query.ProductKey is not null)
dbQuery = dbQuery.Where(e => e.ProductKey == query.ProductKey);
if (query.Status.HasValue)
{
var statusStr = MapStatus(query.Status.Value);
dbQuery = dbQuery.Where(e => e.Status == statusStr);
}
if (query.Outcome.HasValue)
{
var outcomeStr = MapOutcome(query.Outcome.Value);
dbQuery = dbQuery.Where(e => e.Outcome == outcomeStr);
}
if (query.MinimumConfidence.HasValue)
dbQuery = dbQuery.Where(e => e.ConfidenceScore >= query.MinimumConfidence.Value);
if (query.ComputedAfter.HasValue)
{
var afterUtc = query.ComputedAfter.Value.UtcDateTime;
dbQuery = dbQuery.Where(e => e.ComputedAt >= afterUtc);
}
if (query.ComputedBefore.HasValue)
{
var beforeUtc = query.ComputedBefore.Value.UtcDateTime;
dbQuery = dbQuery.Where(e => e.ComputedAt <= beforeUtc);
}
if (query.StatusChanged.HasValue)
dbQuery = dbQuery.Where(e => e.StatusChanged == query.StatusChanged.Value);
// 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));
var totalCount = await dbQuery.CountAsync(cancellationToken);
// Get projections
var projections = new List<ConsensusProjection>();
await using var cmd = new NpgsqlCommand(sql, connection);
foreach (var (name, value) in parameters)
// Apply sorting
dbQuery = query.SortBy switch
{
cmd.Parameters.AddWithValue(name, value);
}
ProjectionSortField.StoredAt => query.SortDescending
? dbQuery.OrderByDescending(e => e.StoredAt)
: dbQuery.OrderBy(e => e.StoredAt),
ProjectionSortField.VulnerabilityId => query.SortDescending
? dbQuery.OrderByDescending(e => e.VulnerabilityId)
: dbQuery.OrderBy(e => e.VulnerabilityId),
ProjectionSortField.ProductKey => query.SortDescending
? dbQuery.OrderByDescending(e => e.ProductKey)
: dbQuery.OrderBy(e => e.ProductKey),
ProjectionSortField.ConfidenceScore => query.SortDescending
? dbQuery.OrderByDescending(e => e.ConfidenceScore)
: dbQuery.OrderBy(e => e.ConfidenceScore),
_ => query.SortDescending
? dbQuery.OrderByDescending(e => e.ComputedAt)
: dbQuery.OrderBy(e => e.ComputedAt)
};
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
projections.Add(MapProjection(reader));
}
// Apply pagination
var entities = await dbQuery
.Skip(query.Offset)
.Take(query.Limit)
.ToListAsync(cancellationToken);
var projections = entities.Select(MapToProjection).ToList();
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
}
@@ -248,20 +289,18 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
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);
await using var dbContext = CreateDbContext(connection);
var projections = new List<ConsensusProjection>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
projections.Add(MapProjection(reader));
}
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& (tenantId == null ? e.TenantId == null : e.TenantId == tenantId))
.OrderByDescending(e => e.ComputedAt)
.Take(limit ?? 100)
.ToListAsync(cancellationToken);
return projections;
return entities.Select(MapToProjection).ToList();
}
/// <inheritdoc />
@@ -274,98 +313,46 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
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);
}
await using var dbContext = CreateDbContext(connection);
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
var olderThanUtc = olderThan.UtcDateTime;
IQueryable<ConsensusProjectionEntity> query = dbContext.ConsensusProjections
.Where(e => e.ComputedAt < olderThanUtc);
if (tenantId is not null)
query = query.Where(e => e.TenantId == tenantId);
var deleted = await query.ExecuteDeleteAsync(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)
private EfCore.Context.VexLensDbContext CreateDbContext(NpgsqlConnection connection)
{
return VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, VexLensDataSource.DefaultSchemaName);
}
private static ConsensusProjection MapToProjection(ConsensusProjectionEntity entity)
{
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));
ProjectionId: entity.Id.ToString(),
VulnerabilityId: entity.VulnerabilityId,
ProductKey: entity.ProductKey,
TenantId: entity.TenantId,
Status: ParseStatus(entity.Status),
Justification: entity.Justification is not null ? ParseJustification(entity.Justification) : null,
ConfidenceScore: entity.ConfidenceScore,
Outcome: ParseOutcome(entity.Outcome),
StatementCount: entity.StatementCount,
ConflictCount: entity.ConflictCount,
RationaleSummary: entity.RationaleSummary ?? string.Empty,
ComputedAt: new DateTimeOffset(entity.ComputedAt, TimeSpan.Zero),
StoredAt: new DateTimeOffset(entity.StoredAt, TimeSpan.Zero),
PreviousProjectionId: entity.PreviousProjectionId?.ToString(),
StatusChanged: entity.StatusChanged);
}
private static string MapStatus(VexStatus status) => status switch
@@ -428,96 +415,6 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
_ => 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,

View File

@@ -19,7 +19,7 @@ public sealed class VexLensDataSource : DataSourceBase
/// <summary>
/// Default schema name for VexLens tables.
/// </summary>
public const string DefaultSchemaName = "vex";
public const string DefaultSchemaName = "vexlens";
public VexLensDataSource(
IOptions<PostgresOptions> options,

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.VexLens.Persistence.EfCore.CompiledModels;
using StellaOps.VexLens.Persistence.EfCore.Context;
namespace StellaOps.VexLens.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="VexLensDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class VexLensDbContextFactory
{
public static VexLensDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? VexLensDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<VexLensDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, VexLensDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(VexLensDbContextModel.Instance);
}
return new VexLensDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,78 +1,67 @@
// -----------------------------------------------------------------------------
// ConsensusProjectionRepository.cs
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-006)
// Task: Implement IConsensusProjectionRepository
// Sprint: SPRINT_20260222_071_VexLens_dal_to_efcore (VEXLENS-EF-03)
// Task: Convert DAL repository from Npgsql to EF Core
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using Npgsql;
using StellaOps.VexLens.Persistence.EfCore.Models;
using StellaOps.VexLens.Persistence.Postgres;
using System.Text.Json;
namespace StellaOps.VexLens.Persistence.Repositories;
/// <summary>
/// PostgreSQL implementation of consensus projection repository.
/// EF Core implementation of consensus projection repository.
/// </summary>
public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSource>, IConsensusProjectionRepository
public sealed class ConsensusProjectionRepository : IConsensusProjectionRepository
{
private const string Schema = "vex";
private const string Table = "consensus_projections";
private const string FullTable = $"{Schema}.{Table}";
private const int CommandTimeoutSeconds = 30;
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly VexLensDataSource _dataSource;
private readonly ILogger<ConsensusProjectionRepository> _logger;
public ConsensusProjectionRepository(
VexLensDataSource dataSource,
ILogger<ConsensusProjectionRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<ConsensusProjection> AddAsync(
ConsensusProjection projection,
CancellationToken ct = default)
{
const string sql = $"""
INSERT INTO {FullTable} (
id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, previous_projection_id, status_changed
)
VALUES (
@id, @tenantId, @vulnId, @productKey, @status,
@confidence, @outcome, @stmtCount, @conflictCount,
@mergeTrace::jsonb, @computedAt, @previousId, @statusChanged
)
RETURNING id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
""";
await using var connection = await OpenConnectionAsync(projection.TenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var result = await QuerySingleOrDefaultAsync(
projection.TenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "id", projection.Id);
AddParameter(cmd, "tenantId", projection.TenantId);
AddParameter(cmd, "vulnId", projection.VulnerabilityId);
AddParameter(cmd, "productKey", projection.ProductKey);
AddParameter(cmd, "status", projection.Status.ToString().ToLowerInvariant());
AddParameter(cmd, "confidence", projection.ConfidenceScore);
AddParameter(cmd, "outcome", projection.Outcome);
AddParameter(cmd, "stmtCount", projection.StatementCount);
AddParameter(cmd, "conflictCount", projection.ConflictCount);
AddParameter(cmd, "mergeTrace", SerializeTrace(projection.Trace));
AddParameter(cmd, "computedAt", projection.ComputedAt);
AddParameter(cmd, "previousId", (object?)projection.PreviousProjectionId ?? DBNull.Value);
AddParameter(cmd, "statusChanged", projection.StatusChanged);
},
MapProjection,
ct);
var entity = ToEntity(projection);
dbContext.ConsensusProjections.Add(entity);
return result ?? throw new InvalidOperationException("Failed to add consensus projection");
try
{
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
_logger.LogWarning(
"Duplicate consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}; idempotent skip.",
projection.Id, projection.VulnerabilityId, projection.ProductKey);
}
// Re-read to get DB-generated stored_at
await using var readConnection = await OpenConnectionAsync(projection.TenantId.ToString(), ct);
await using var readContext = VexLensDbContextFactory.Create(readConnection, CommandTimeoutSeconds, GetSchemaName());
var stored = await readContext.ConsensusProjections
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == projection.Id, ct);
return stored is not null ? ToModel(stored) : projection;
}
public async ValueTask<ConsensusProjection?> GetLatestAsync(
@@ -81,29 +70,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
Guid tenantId,
CancellationToken ct = default)
{
const string sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE vulnerability_id = @vulnId
AND product_key = @productKey
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT 1
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QuerySingleOrDefaultAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "vulnId", vulnerabilityId);
AddParameter(cmd, "productKey", productKey);
AddParameter(cmd, "tenantId", tenantId);
},
MapProjection,
ct);
var entity = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.FirstOrDefaultAsync(ct);
return entity is not null ? ToModel(entity) : null;
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByVulnerabilityAsync(
@@ -112,28 +90,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 100,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE vulnerability_id = @vulnId
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "vulnId", vulnerabilityId);
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByProductAsync(
@@ -142,28 +110,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 100,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE product_key = @productKey
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "productKey", productKey);
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.ProductKey == productKey
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetStatusChangesAsync(
@@ -172,29 +130,21 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 100,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE tenant_id = @tenantId
AND status_changed = TRUE
AND computed_at >= @since
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "since", since);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var sinceUtc = since.UtcDateTime;
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.TenantId == tenantId.ToString()
&& e.StatusChanged == true
&& e.ComputedAt >= sinceUtc)
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
@@ -204,36 +154,52 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
int limit = 50,
CancellationToken ct = default)
{
var sql = $"""
SELECT id, tenant_id, vulnerability_id, product_key, status,
confidence_score, outcome, statement_count, conflict_count,
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
FROM {FullTable}
WHERE vulnerability_id = @vulnId
AND product_key = @productKey
AND tenant_id = @tenantId
ORDER BY computed_at DESC
LIMIT @limit
""";
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId.ToString(),
sql,
cmd =>
{
AddParameter(cmd, "vulnId", vulnerabilityId);
AddParameter(cmd, "productKey", productKey);
AddParameter(cmd, "tenantId", tenantId);
AddParameter(cmd, "limit", limit);
},
MapProjection,
ct);
var entities = await dbContext.ConsensusProjections
.AsNoTracking()
.Where(e => e.VulnerabilityId == vulnerabilityId
&& e.ProductKey == productKey
&& e.TenantId == tenantId.ToString())
.OrderByDescending(e => e.ComputedAt)
.Take(limit)
.ToListAsync(ct);
return entities.Select(ToModel).ToList();
}
private static ConsensusProjection MapProjection(System.Data.Common.DbDataReader reader)
#region Mapping
private static ConsensusProjectionEntity ToEntity(ConsensusProjection model) => new()
{
var statusStr = reader.GetString(reader.GetOrdinal("status"));
var status = statusStr.ToLowerInvariant() switch
Id = model.Id,
TenantId = model.TenantId.ToString(),
VulnerabilityId = model.VulnerabilityId,
ProductKey = model.ProductKey,
Status = model.Status.ToString().ToLowerInvariant() switch
{
"unknown" => "unknown",
"underinvestigation" => "under_investigation",
"notaffected" => "not_affected",
"affected" => "affected",
"fixed" => "fixed",
_ => model.Status.ToString().ToLowerInvariant()
},
ConfidenceScore = (double)model.ConfidenceScore,
Outcome = model.Outcome,
StatementCount = model.StatementCount,
ConflictCount = model.ConflictCount,
MergeTrace = SerializeTrace(model.Trace),
ComputedAt = model.ComputedAt.UtcDateTime,
PreviousProjectionId = model.PreviousProjectionId,
StatusChanged = model.StatusChanged
};
private static ConsensusProjection ToModel(ConsensusProjectionEntity entity)
{
var statusStr = entity.Status.ToLowerInvariant();
var status = statusStr switch
{
"unknown" => VexConsensusStatus.Unknown,
"under_investigation" => VexConsensusStatus.UnderInvestigation,
@@ -243,28 +209,21 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
_ => throw new InvalidOperationException($"Unknown status: {statusStr}")
};
var traceJson = reader.IsDBNull(reader.GetOrdinal("merge_trace"))
? null
: reader.GetString(reader.GetOrdinal("merge_trace"));
return new ConsensusProjection(
Id: reader.GetGuid(reader.GetOrdinal("id")),
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
ProductKey: reader.GetString(reader.GetOrdinal("product_key")),
Id: entity.Id,
TenantId: Guid.TryParse(entity.TenantId, out var tenantId) ? tenantId : Guid.Empty,
VulnerabilityId: entity.VulnerabilityId,
ProductKey: entity.ProductKey,
Status: status,
ConfidenceScore: reader.GetDecimal(reader.GetOrdinal("confidence_score")),
Outcome: reader.GetString(reader.GetOrdinal("outcome")),
StatementCount: reader.GetInt32(reader.GetOrdinal("statement_count")),
ConflictCount: reader.GetInt32(reader.GetOrdinal("conflict_count")),
Trace: DeserializeTrace(traceJson),
ComputedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("computed_at")),
StoredAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("stored_at")),
PreviousProjectionId: reader.IsDBNull(reader.GetOrdinal("previous_projection_id"))
? null
: reader.GetGuid(reader.GetOrdinal("previous_projection_id")),
StatusChanged: reader.GetBoolean(reader.GetOrdinal("status_changed"))
);
ConfidenceScore: (decimal)entity.ConfidenceScore,
Outcome: entity.Outcome,
StatementCount: entity.StatementCount,
ConflictCount: entity.ConflictCount,
Trace: DeserializeTrace(entity.MergeTrace),
ComputedAt: new DateTimeOffset(entity.ComputedAt, TimeSpan.Zero),
StoredAt: new DateTimeOffset(entity.StoredAt, TimeSpan.Zero),
PreviousProjectionId: entity.PreviousProjectionId,
StatusChanged: entity.StatusChanged);
}
private static string SerializeTrace(MergeTrace? trace)
@@ -282,4 +241,29 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
return JsonSerializer.Deserialize<MergeTrace>(json, SerializerOptions);
}
#endregion
#region Helpers
private async Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, CancellationToken ct)
{
return await _dataSource.OpenConnectionAsync(tenantId, "reader", ct);
}
private static string GetSchemaName() => VexLensDataSource.DefaultSchemaName;
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
#endregion
}

View File

@@ -10,7 +10,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
@@ -18,11 +21,17 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\VexLensDbContextAssemblyAttributes.cs" />
</ItemGroup>
</Project>

View File

@@ -29,7 +29,8 @@ public static class ExportEndpointExtensions
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/vexlens/export")
.WithTags("VexLens Export");
.WithTags("VexLens Export")
.RequireAuthorization("vexlens.read");
// Export consensus result
group.MapGet("/consensus/{vulnerabilityId}/{productId}", ExportConsensusAsync)

View File

@@ -19,122 +19,126 @@ public static class VexLensEndpointExtensions
public static IEndpointRouteBuilder MapVexLensEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/vexlens")
.WithTags("VexLens");
.WithTags("VexLens")
.RequireAuthorization("vexlens.read");
// Consensus endpoints
group.MapPost("/consensus", ComputeConsensusAsync)
.WithName("ComputeConsensus")
.WithDescription("Compute consensus for a vulnerability-product pair")
.WithDescription("Evaluates all registered VEX statements for the given vulnerability-product pair and returns a weighted consensus disposition, confidence score, and contributing issuer breakdown. Returns 400 if inputs are missing or unparseable.")
.Produces<ComputeConsensusResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/consensus:withProof", ComputeConsensusWithProofAsync)
.WithName("ComputeConsensusWithProof")
.WithDescription("Compute consensus with full proof object for audit trail")
.WithDescription("Computes VEX consensus and includes the full proof object containing per-issuer verdicts, trust weights, and intermediate scoring steps. Use this variant when an auditable evidence trail is required alongside the consensus result.")
.Produces<ComputeConsensusWithProofResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/consensus:batch", ComputeConsensusBatchAsync)
.WithName("ComputeConsensusBatch")
.WithDescription("Compute consensus for multiple vulnerability-product pairs")
.WithDescription("Submits multiple vulnerability-product pairs in a single request and returns consensus results for each pair in order. Pairs that fail individually are reported with their error; other pairs in the batch are still evaluated.")
.Produces<ComputeConsensusBatchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// Projection endpoints
group.MapGet("/projections", QueryProjectionsAsync)
.WithName("QueryProjections")
.WithDescription("Query consensus projections with filtering")
.WithDescription("Queries stored consensus projections with optional filtering by vulnerability ID, product key, outcome, confidence threshold, and computation time range. Returns a paginated list ordered by the requested sort field.")
.Produces<QueryProjectionsResponse>(StatusCodes.Status200OK);
group.MapGet("/projections/{projectionId}", GetProjectionAsync)
.WithName("GetProjection")
.WithDescription("Get a specific consensus projection by ID")
.WithDescription("Returns the full detail record for a specific consensus projection by its unique ID including disposition, confidence, source statements, and conflict indicators. Returns 404 if the projection ID is not found.")
.Produces<ProjectionDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/projections/latest", GetLatestProjectionAsync)
.WithName("GetLatestProjection")
.WithDescription("Get the latest projection for a vulnerability-product pair")
.WithDescription("Returns the most recently computed consensus projection for the specified vulnerability-product pair. Returns 404 if no projection has been computed yet for the pair.")
.Produces<ProjectionDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/projections/history", GetProjectionHistoryAsync)
.WithName("GetProjectionHistory")
.WithDescription("Get projection history for a vulnerability-product pair")
.WithDescription("Returns the ordered history of consensus projections computed for the specified vulnerability-product pair, newest first. Use this to track how the consensus disposition has changed over time as new VEX statements were ingested.")
.Produces<ProjectionHistoryResponse>(StatusCodes.Status200OK);
// Statistics endpoint
group.MapGet("/stats", GetStatisticsAsync)
.WithName("GetVexLensStatistics")
.WithDescription("Get consensus projection statistics")
.WithDescription("Returns aggregate statistics for consensus projections scoped to the tenant including total projection count, distribution by disposition, average confidence score, and conflict rate.")
.Produces<ConsensusStatisticsResponse>(StatusCodes.Status200OK);
// Conflict endpoints
group.MapGet("/conflicts", GetConflictsAsync)
.WithName("GetConflicts")
.WithDescription("Get projections with conflicts")
.WithDescription("Returns consensus projections that have one or more issuer conflicts, ordered by most recently computed. Conflict projections are those where issuer dispositions disagree, reducing the overall confidence score.")
.Produces<QueryProjectionsResponse>(StatusCodes.Status200OK);
// Delta/Noise-Gating endpoints
var deltaGroup = app.MapGroup("/api/v1/vexlens/deltas")
.WithTags("VexLens Delta");
.WithTags("VexLens Delta")
.RequireAuthorization("vexlens.read");
deltaGroup.MapPost("/compute", ComputeDeltaAsync)
.WithName("ComputeDelta")
.WithDescription("Compute delta report between two snapshots")
.WithDescription("Computes a structured delta report between two named VEX snapshots, identifying added, removed, and changed vulnerability-product dispositions. Returns 400 if either snapshot ID is not found.")
.Produces<DeltaReportResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
var gatingGroup = app.MapGroup("/api/v1/vexlens/gating")
.WithTags("VexLens Gating");
.WithTags("VexLens Gating")
.RequireAuthorization("vexlens.read");
gatingGroup.MapGet("/statistics", GetGatingStatisticsAsync)
.WithName("GetGatingStatistics")
.WithDescription("Get aggregated noise-gating statistics")
.WithDescription("Returns aggregated noise-gating statistics for the tenant over an optional time window, including total snapshots processed, edge reduction percentages, total verdicts surfaced versus damped, and average damping rate.")
.Produces<AggregatedGatingStatisticsResponse>(StatusCodes.Status200OK);
gatingGroup.MapPost("/snapshots/{snapshotId}/gate", GateSnapshotAsync)
.WithName("GateSnapshot")
.WithDescription("Apply noise-gating to a snapshot")
.WithDescription("Applies noise-gating rules to the specified VEX snapshot, filtering low-signal edges and low-confidence verdicts. Returns the gated snapshot with edge and verdict counts after filtering. Returns 404 if the snapshot ID is not found.")
.Produces<GatedSnapshotResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// Issuer endpoints
var issuerGroup = app.MapGroup("/api/v1/vexlens/issuers")
.WithTags("VexLens Issuers");
.WithTags("VexLens Issuers")
.RequireAuthorization("vexlens.write");
issuerGroup.MapGet("/", ListIssuersAsync)
.WithName("ListIssuers")
.WithDescription("List registered VEX issuers")
.WithDescription("Returns the list of VEX issuers registered with VexLens, optionally filtered by category, minimum trust tier, status, or search term. Results are paginated via limit and offset parameters.")
.Produces<IssuerListResponse>(StatusCodes.Status200OK);
issuerGroup.MapGet("/{issuerId}", GetIssuerAsync)
.WithName("GetIssuer")
.WithDescription("Get issuer details")
.WithDescription("Returns the full detail record for a specific VEX issuer by ID including trust tier, registered keys, category, and current status. Returns 404 if the issuer ID is not registered.")
.Produces<IssuerDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
issuerGroup.MapPost("/", RegisterIssuerAsync)
.WithName("RegisterIssuer")
.WithDescription("Register a new VEX issuer")
.WithDescription("Registers a new VEX issuer with VexLens, associating an issuer ID with a trust tier, category, and optional initial keys. Returns 201 Created with the new issuer detail record. Returns 400 if the issuer ID is already registered or inputs are invalid.")
.Produces<IssuerDetailResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
issuerGroup.MapDelete("/{issuerId}", RevokeIssuerAsync)
.WithName("RevokeIssuer")
.WithDescription("Revoke an issuer")
.WithDescription("Revokes a VEX issuer from VexLens, causing future consensus computations to exclude all VEX statements from this issuer. Returns 204 No Content on success. Returns 404 if the issuer is not registered.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
issuerGroup.MapPost("/{issuerId}/keys", AddIssuerKeyAsync)
.WithName("AddIssuerKey")
.WithDescription("Add a key to an issuer")
.WithDescription("Adds a cryptographic public key to an existing VEX issuer for statement signature verification. Returns the updated issuer detail record with the new key included. Returns 404 if the issuer is not found.")
.Produces<IssuerDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
issuerGroup.MapDelete("/{issuerId}/keys/{fingerprint}", RevokeIssuerKeyAsync)
.WithName("RevokeIssuerKey")
.WithDescription("Revoke an issuer key")
.WithDescription("Revokes a specific cryptographic key from a VEX issuer by fingerprint, preventing future VEX statements signed with that key from being accepted. Returns 204 No Content on success. Returns 404 if either the issuer or key fingerprint is not found.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);

View File

@@ -73,6 +73,14 @@ builder.Services.AddRateLimiter(options =>
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
// RASD-03: Register scope-based authorization policies for VexLens endpoints.
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddAuthorization(auth =>
{
auth.AddStellaOpsScopePolicy("vexlens.read", "vexlens.read");
auth.AddStellaOpsScopePolicy("vexlens.write", "vexlens.write");
});
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
@@ -90,6 +98,8 @@ if (app.Environment.IsDevelopment())
}
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerEnabled);
app.UseRateLimiter();
app.UseSerilogRequestLogging();