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

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

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Infrastructure.EfCore.Context;
namespace StellaOps.Unknowns.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Unknowns module.
/// </summary>
/// <remarks>
/// This is a placeholder. Run the scaffolding script to generate the full context:
/// <code>
/// .\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns
/// </code>
/// </remarks>
public class UnknownsDbContext : StellaOpsDbContextBase
{
/// <inheritdoc />
protected override string SchemaName => "unknowns";
/// <summary>
/// Creates a new UnknownsDbContext.
/// </summary>
public UnknownsDbContext(DbContextOptions<UnknownsDbContext> options) : base(options)
{
}
// DbSet properties will be generated by scaffolding:
// public virtual DbSet<Unknown> Unknowns { get; set; } = null!;
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Entity configurations will be generated by scaffolding
// For now, configure any manual customizations here
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.EfCore.Extensions;
using StellaOps.Infrastructure.EfCore.Tenancy;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence.EfCore.Repositories;
namespace StellaOps.Unknowns.Persistence.EfCore.Extensions;
/// <summary>
/// Extension methods for registering Unknowns EF Core persistence.
/// </summary>
public static class UnknownsPersistenceExtensions
{
private const string SchemaName = "unknowns";
/// <summary>
/// Registers EF Core persistence for the Unknowns module.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsEfCorePersistence(
this IServiceCollection services,
string connectionString)
{
// Register DbContext with tenant isolation
services.AddStellaOpsDbContext<Context.UnknownsDbContext>(
connectionString,
SchemaName);
// Register repository implementations
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
return services;
}
/// <summary>
/// Registers EF Core persistence for the Unknowns module with compiled model.
/// Use this overload for production deployments for faster startup.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsEfCorePersistenceWithCompiledModel(
this IServiceCollection services,
string connectionString)
{
// Register DbContext with compiled model and tenant isolation
// Uncomment when compiled models are generated:
// services.AddStellaOpsDbContextWithCompiledModel<Context.UnknownsDbContext, CompiledModels.UnknownsDbContextModel>(
// connectionString,
// SchemaName,
// CompiledModels.UnknownsDbContextModel.Instance);
// For now, use standard registration
services.AddStellaOpsDbContext<Context.UnknownsDbContext>(
connectionString,
SchemaName);
// Register repository implementations
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
return services;
}
/// <summary>
/// Registers a fallback tenant context accessor that always uses "_system".
/// Use for worker services or migrations.
/// </summary>
public static IServiceCollection AddUnknownsSystemTenantContext(this IServiceCollection services)
{
services.AddSingleton<ITenantContextAccessor>(SystemTenantContextAccessor.Instance);
return services;
}
}

View File

@@ -0,0 +1,54 @@
# StellaOps.Unknowns.Persistence.EfCore
EF Core persistence layer for the Unknowns module using database-first scaffolding.
## Directory Structure
```
├── Context/
│ └── UnknownsDbContext.cs # Scaffolded from database
├── Entities/
│ └── *.cs # Scaffolded from database
├── CompiledModels/
│ └── *.cs # Generated for performance
├── Repositories/
│ └── UnknownEfRepository.cs # Repository implementation
└── Extensions/
└── UnknownsPersistenceExtensions.cs # DI registration
```
## Scaffolding
To scaffold/regenerate from the database:
```powershell
# From repository root
.\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns
```
Or on Linux/macOS:
```bash
./devops/scripts/efcore/scaffold-module.sh Unknowns
```
## Prerequisites
1. PostgreSQL running with `unknowns` schema initialized:
```bash
docker compose -f devops/compose/docker-compose.dev.yaml up -d postgres
```
2. Migrations applied:
```bash
dotnet run --project src/Unknowns/__Libraries/StellaOps.Unknowns.Storage.Postgres -- migrate
```
## Usage
Register in `Program.cs`:
```csharp
builder.Services.AddUnknownsEfCorePersistence(
connectionString: configuration.GetConnectionString("Unknowns")!);
```

View File

@@ -0,0 +1,291 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence.EfCore.Context;
namespace StellaOps.Unknowns.Persistence.EfCore.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IUnknownRepository"/>.
/// </summary>
/// <remarks>
/// This is a placeholder implementation. After scaffolding, update to use the generated entities.
/// For complex queries (CTEs, window functions), use raw SQL via <see cref="UnknownsDbContext.RawSqlQueryAsync{T}"/>.
/// </remarks>
public sealed class UnknownEfRepository : IUnknownRepository
{
private readonly UnknownsDbContext _context;
/// <summary>
/// Creates a new UnknownEfRepository.
/// </summary>
public UnknownEfRepository(UnknownsDbContext context)
{
_context = context;
}
/// <inheritdoc />
public Task<Unknown> CreateAsync(
string tenantId,
UnknownSubjectType subjectType,
string subjectRef,
UnknownKind kind,
UnknownSeverity? severity,
string? context,
Guid? sourceScanId,
Guid? sourceGraphId,
string? sourceSbomDigest,
string createdBy,
CancellationToken cancellationToken)
{
// TODO: Implement after scaffolding generates entities
throw new NotImplementedException("Scaffold entities first: ./devops/scripts/efcore/Scaffold-Module.ps1 -Module Unknowns");
}
/// <inheritdoc />
public Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
{
// TODO: Implement after scaffolding
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
string tenantId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByKindAsync(
string tenantId,
UnknownKind kind,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
string tenantId,
UnknownSeverity severity,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
string tenantId,
Guid scanId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public async Task<IReadOnlyList<Unknown>> AsOfAsync(
string tenantId,
DateTimeOffset validAt,
DateTimeOffset? systemAt = null,
CancellationToken cancellationToken = default)
{
// Bitemporal query - use raw SQL for efficiency
var sysAt = systemAt ?? DateTimeOffset.UtcNow;
// This is a complex query that benefits from raw SQL
var results = await _context.RawSqlQueryAsync<UnknownDto>($"""
SELECT * FROM unknowns.unknown
WHERE tenant_id = {tenantId}
AND valid_from <= {validAt}
AND (valid_to IS NULL OR valid_to > {validAt})
AND sys_from <= {sysAt}
AND (sys_to IS NULL OR sys_to > {sysAt})
ORDER BY created_at DESC
""", cancellationToken);
return results.Select(MapToModel).ToList();
}
/// <inheritdoc />
public Task<Unknown> ResolveAsync(
string tenantId,
Guid id,
ResolutionType resolutionType,
string? resolutionRef,
string? resolutionNotes,
string resolvedBy,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task SupersedeAsync(
string tenantId,
Guid id,
string supersededBy,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
string tenantId,
CancellationToken cancellationToken)
{
// Use raw SQL for grouping
var results = await _context.RawSqlQueryAsync<KindCount>($"""
SELECT kind, count(*) as count
FROM unknowns.unknown
WHERE tenant_id = {tenantId}
AND valid_to IS NULL
AND sys_to IS NULL
GROUP BY kind
""", cancellationToken);
return results.ToDictionary(
r => Enum.Parse<UnknownKind>(r.Kind, ignoreCase: true),
r => r.Count);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
string tenantId,
CancellationToken cancellationToken)
{
var results = await _context.RawSqlQueryAsync<SeverityCount>($"""
SELECT severity, count(*) as count
FROM unknowns.unknown
WHERE tenant_id = {tenantId}
AND valid_to IS NULL
AND sys_to IS NULL
AND severity IS NOT NULL
GROUP BY severity
""", cancellationToken);
return results.ToDictionary(
r => Enum.Parse<UnknownSeverity>(r.Severity, ignoreCase: true),
r => r.Count);
}
/// <inheritdoc />
public async Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
{
var result = await _context.RawSqlQueryAsync<CountResult>($"""
SELECT count(*) as count
FROM unknowns.unknown
WHERE tenant_id = {tenantId}
AND valid_to IS NULL
AND sys_to IS NULL
""", cancellationToken);
return result.FirstOrDefault()?.Count ?? 0;
}
// Triage methods
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
string tenantId,
TriageBand band,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown> UpdateScoresAsync(
string tenantId,
Guid id,
double popularityScore,
int deploymentCount,
double exploitPotentialScore,
double uncertaintyScore,
string? uncertaintyFlags,
double centralityScore,
int degreeCentrality,
double betweennessCentrality,
double stalenessScore,
int daysSinceAnalysis,
double compositeScore,
TriageBand triageBand,
string? scoringTrace,
DateTimeOffset? nextScheduledRescan,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown> RecordRescanAttemptAsync(
string tenantId,
Guid id,
string result,
DateTimeOffset? nextRescan,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
// Helper DTOs for raw SQL queries
private sealed record UnknownDto;
private sealed record KindCount(string Kind, long Count);
private sealed record SeverityCount(string Severity, long Count);
private sealed record CountResult(long Count);
private static Unknown MapToModel(UnknownDto dto)
{
// TODO: Implement mapping from scaffolded entity to domain model
throw new NotImplementedException("Implement after scaffolding");
}
}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Unknowns.Persistence.EfCore</RootNamespace>
<AssemblyName>StellaOps.Unknowns.Persistence.EfCore</AssemblyName>
<Description>EF Core persistence layer for StellaOps Unknowns module with database-first scaffolding</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
# Placeholder for EF Core compiled models
# Generated by: dotnet ef dbcontext optimize

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Infrastructure.EfCore.Context;
namespace StellaOps.Unknowns.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Unknowns module.
/// </summary>
/// <remarks>
/// This is a placeholder. Run the scaffolding script to generate the full context:
/// <code>
/// .\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns
/// </code>
/// </remarks>
public class UnknownsDbContext : StellaOpsDbContextBase
{
/// <inheritdoc />
protected override string SchemaName => "unknowns";
/// <summary>
/// Creates a new UnknownsDbContext.
/// </summary>
public UnknownsDbContext(DbContextOptions<UnknownsDbContext> options) : base(options)
{
}
// DbSet properties will be generated by scaffolding:
// public virtual DbSet<Unknown> Unknowns { get; set; } = null!;
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Entity configurations will be generated by scaffolding
// For now, configure any manual customizations here
}
}

View File

@@ -0,0 +1,2 @@
# Placeholder for scaffolded EF Core entities
# Run: .\devops\scripts\efcore\Scaffold-Module.ps1 -Module Unknowns

View File

@@ -0,0 +1 @@
# Placeholder for entity-to-domain model mappings

View File

@@ -0,0 +1,234 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence.EfCore.Context;
namespace StellaOps.Unknowns.Persistence.EfCore.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IUnknownRepository"/>.
/// </summary>
/// <remarks>
/// This is a placeholder implementation. After scaffolding, update to use the generated entities.
/// For complex queries (CTEs, window functions), use raw SQL via <see cref="UnknownsDbContext"/>.
/// </remarks>
public sealed class UnknownEfRepository : IUnknownRepository
{
private readonly UnknownsDbContext _context;
/// <summary>
/// Creates a new UnknownEfRepository.
/// </summary>
public UnknownEfRepository(UnknownsDbContext context)
{
_context = context;
}
/// <inheritdoc />
public Task<Unknown> CreateAsync(
string tenantId,
UnknownSubjectType subjectType,
string subjectRef,
UnknownKind kind,
UnknownSeverity? severity,
string? context,
Guid? sourceScanId,
Guid? sourceGraphId,
string? sourceSbomDigest,
string createdBy,
CancellationToken cancellationToken)
{
// TODO: Implement after scaffolding generates entities
throw new NotImplementedException("Scaffold entities first: ./devops/scripts/efcore/Scaffold-Module.ps1 -Module Unknowns");
}
/// <inheritdoc />
public Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
{
// TODO: Implement after scaffolding
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
string tenantId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByKindAsync(
string tenantId,
UnknownKind kind,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
string tenantId,
UnknownSeverity severity,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
string tenantId,
Guid scanId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> AsOfAsync(
string tenantId,
DateTimeOffset validAt,
DateTimeOffset? systemAt = null,
CancellationToken cancellationToken = default)
{
// Bitemporal query - will use raw SQL for efficiency after scaffolding
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown> ResolveAsync(
string tenantId,
Guid id,
ResolutionType resolutionType,
string? resolutionRef,
string? resolutionNotes,
string resolvedBy,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task SupersedeAsync(
string tenantId,
Guid id,
string supersededBy,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
// Triage methods
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
string tenantId,
TriageBand band,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown> UpdateScoresAsync(
string tenantId,
Guid id,
double popularityScore,
int deploymentCount,
double exploitPotentialScore,
double uncertaintyScore,
string? uncertaintyFlags,
double centralityScore,
int degreeCentrality,
double betweennessCentrality,
double stalenessScore,
int daysSinceAnalysis,
double compositeScore,
TriageBand triageBand,
string? scoringTrace,
DateTimeOffset? nextScheduledRescan,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<Unknown> RecordRescanAttemptAsync(
string tenantId,
Guid id,
string result,
DateTimeOffset? nextRescan,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
/// <inheritdoc />
public Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
string tenantId,
CancellationToken cancellationToken)
{
throw new NotImplementedException("Scaffold entities first");
}
}

View File

@@ -0,0 +1,136 @@
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StellaOps.Infrastructure.EfCore.Extensions;
using StellaOps.Infrastructure.EfCore.Tenancy;
using StellaOps.Unknowns.Core.Persistence;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence.EfCore.Context;
using StellaOps.Unknowns.Persistence.EfCore.Repositories;
using StellaOps.Unknowns.Persistence.Postgres;
using StellaOps.Unknowns.Persistence.Postgres.Repositories;
namespace StellaOps.Unknowns.Persistence.Extensions;
/// <summary>
/// Extension methods for registering Unknowns persistence services.
/// </summary>
/// <remarks>
/// Provides three persistence strategies:
/// <list type="bullet">
/// <item><see cref="AddUnknownsPersistence"/> - EF Core (recommended)</item>
/// <item><see cref="AddUnknownsPersistenceRawSql"/> - Raw SQL for complex queries</item>
/// <item><see cref="AddUnknownsPersistenceInMemory"/> - In-memory for testing</item>
/// </list>
/// </remarks>
public static class UnknownsPersistenceExtensions
{
private const string SchemaName = "unknowns";
/// <summary>
/// Registers EF Core persistence for the Unknowns module (recommended).
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistence(
this IServiceCollection services,
string connectionString)
{
// Register DbContext with tenant isolation
services.AddStellaOpsDbContext<UnknownsDbContext>(
connectionString,
SchemaName);
// Register EF Core repository implementations
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
// Register persister (wraps repository)
services.AddScoped<IUnknownPersister, PostgresUnknownPersister>();
return services;
}
/// <summary>
/// Registers EF Core persistence with compiled model for faster startup.
/// Use this overload for production deployments.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistenceWithCompiledModel(
this IServiceCollection services,
string connectionString)
{
// Register DbContext with compiled model and tenant isolation
// Uncomment when compiled models are generated:
// services.AddStellaOpsDbContextWithCompiledModel<UnknownsDbContext, CompiledModels.UnknownsDbContextModel>(
// connectionString,
// SchemaName,
// CompiledModels.UnknownsDbContextModel.Instance);
// For now, use standard registration
services.AddStellaOpsDbContext<UnknownsDbContext>(
connectionString,
SchemaName);
// Register EF Core repository implementations
services.AddScoped<IUnknownRepository, UnknownEfRepository>();
// Register persister
services.AddScoped<IUnknownPersister, PostgresUnknownPersister>();
return services;
}
/// <summary>
/// Registers raw SQL persistence for the Unknowns module.
/// Use for complex queries (CTEs, window functions) or during migration period.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistenceRawSql(
this IServiceCollection services,
string connectionString)
{
// Register NpgsqlDataSource for raw SQL access
services.AddSingleton<NpgsqlDataSource>(_ =>
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
return dataSourceBuilder.Build();
});
// Register raw SQL repository implementations
services.AddScoped<IUnknownRepository, PostgresUnknownRepository>();
// Register persister
services.AddScoped<IUnknownPersister, PostgresUnknownPersister>();
return services;
}
/// <summary>
/// Registers in-memory persistence for testing.
/// </summary>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddUnknownsPersistenceInMemory(
this IServiceCollection services)
{
// TODO: Implement in-memory repositories for testing
// services.AddSingleton<IUnknownRepository, InMemoryUnknownRepository>();
// services.AddSingleton<IUnknownPersister, InMemoryUnknownPersister>();
throw new NotImplementedException("In-memory persistence not yet implemented. Use AddUnknownsPersistenceRawSql for testing.");
}
/// <summary>
/// Registers a fallback tenant context accessor that always uses "_system".
/// Use for worker services or migrations.
/// </summary>
public static IServiceCollection AddUnknownsSystemTenantContext(this IServiceCollection services)
{
services.AddSingleton<ITenantContextAccessor>(SystemTenantContextAccessor.Instance);
return services;
}
}

View File

@@ -0,0 +1 @@
# Placeholder for in-memory repository implementations (testing)

View File

@@ -0,0 +1,523 @@
-- Unknowns Schema Migration 001: Initial Schema (Consolidated)
-- Consolidated from: 001_initial_schema.sql, 002_scoring_extension.sql
-- Category: A (safe, can run at startup)
--
-- Purpose: Create the unknowns schema with bitemporal semantics for tracking
-- ambiguity in vulnerability scans, enabling point-in-time compliance queries
-- and intelligent triage with HOT/WARM/COLD band assignment.
--
-- Bitemporal Dimensions:
-- - valid_from/valid_to: When the unknown was relevant in the real world
-- - sys_from/sys_to: When the system recorded/knew about the unknown
--
-- Triage Bands:
-- - HOT (>=0.70): Immediate rescan priority
-- - WARM (0.40-0.69): 12-72h rescan window
-- - COLD (<0.40): Weekly rescan
BEGIN;
-- ============================================================================
-- Step 1: Create schemas
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS unknowns;
CREATE SCHEMA IF NOT EXISTS unknowns_app;
-- Tenant context helper function
CREATE OR REPLACE FUNCTION unknowns_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
DECLARE
v_tenant TEXT;
BEGIN
v_tenant := current_setting('app.tenant_id', true);
IF v_tenant IS NULL OR v_tenant = '' THEN
RAISE EXCEPTION 'app.tenant_id session variable not set'
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
ERRCODE = 'P0001';
END IF;
RETURN v_tenant;
END;
$$;
REVOKE ALL ON FUNCTION unknowns_app.require_current_tenant() FROM PUBLIC;
-- ============================================================================
-- Step 2: Create enum types
-- ============================================================================
DO $$ BEGIN
CREATE TYPE unknowns.subject_type AS ENUM (
'package',
'ecosystem',
'version',
'sbom_edge',
'file',
'runtime'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE unknowns.unknown_kind AS ENUM (
'missing_sbom',
'ambiguous_package',
'missing_feed',
'unresolved_edge',
'no_version_info',
'unknown_ecosystem',
'partial_match',
'version_range_unbounded',
'unsupported_format',
'transitive_gap'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE unknowns.unknown_severity AS ENUM (
'critical',
'high',
'medium',
'low',
'info'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE unknowns.resolution_type AS ENUM (
'feed_updated',
'sbom_provided',
'manual_mapping',
'superseded',
'false_positive',
'wont_fix'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE unknowns.triage_band AS ENUM ('hot', 'warm', 'cold');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- ============================================================================
-- Step 3: Create main bitemporal unknowns table with scoring columns
-- ============================================================================
CREATE TABLE IF NOT EXISTS unknowns.unknown (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
-- Subject identification
subject_hash CHAR(64) NOT NULL, -- SHA-256 hex of subject
subject_type unknowns.subject_type NOT NULL,
subject_ref TEXT NOT NULL, -- Human-readable reference (purl, name)
-- Classification
kind unknowns.unknown_kind NOT NULL,
severity unknowns.unknown_severity,
-- Context (flexible JSONB for additional details)
context JSONB NOT NULL DEFAULT '{}',
-- Source correlation
source_scan_id UUID, -- Scan that discovered this unknown
source_graph_id UUID, -- Graph revision context
source_sbom_digest TEXT, -- SBOM digest if applicable
-- Bitemporal columns
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_to TIMESTAMPTZ, -- NULL = currently valid
sys_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sys_to TIMESTAMPTZ, -- NULL = current system state
-- Resolution tracking
resolved_at TIMESTAMPTZ,
resolution_type unknowns.resolution_type,
resolution_ref TEXT, -- Reference to resolving entity
resolution_notes TEXT,
-- Scoring: Popularity (P)
-- Formula: min(1, log10(1 + deployments) / log10(1 + 100))
popularity_score FLOAT DEFAULT 0.0,
deployment_count INT DEFAULT 0,
-- Scoring: Exploit potential (E)
-- Range: 0.0 (no known CVE) to 1.0 (critical CVE + KEV)
exploit_potential_score FLOAT DEFAULT 0.0,
-- Scoring: Uncertainty density (U)
-- Flags: no_provenance(0.30), version_range(0.25), conflicting_feeds(0.20),
-- missing_vector(0.15), unreachable_source(0.10)
uncertainty_score FLOAT DEFAULT 0.0,
uncertainty_flags JSONB DEFAULT '{}'::jsonb,
-- Scoring: Centrality (C)
-- Based on normalized betweenness centrality
centrality_score FLOAT DEFAULT 0.0,
degree_centrality INT DEFAULT 0,
betweenness_centrality FLOAT DEFAULT 0.0,
-- Scoring: Staleness (S)
-- Formula: min(1, age_days / 14)
staleness_score FLOAT DEFAULT 0.0,
days_since_analysis INT DEFAULT 0,
-- Scoring: Composite
-- Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
composite_score FLOAT DEFAULT 0.0,
-- Triage band: HOT (>=0.70), WARM (0.40-0.69), COLD (<0.40)
triage_band unknowns.triage_band DEFAULT 'cold',
-- Normalization trace for audit/debugging
scoring_trace JSONB,
-- Rescan scheduling
rescan_attempts INT DEFAULT 0,
last_rescan_result TEXT,
next_scheduled_rescan TIMESTAMPTZ,
last_analyzed_at TIMESTAMPTZ,
-- Evidence hashes for deterministic replay
evidence_set_hash BYTEA,
graph_slice_hash BYTEA,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL DEFAULT 'system',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT ck_unknown_subject_hash_hex CHECK (subject_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT ck_unknown_resolution_consistency CHECK (
(resolved_at IS NULL AND resolution_type IS NULL) OR
(resolved_at IS NOT NULL AND resolution_type IS NOT NULL)
),
CONSTRAINT ck_unknown_temporal_order CHECK (
(valid_to IS NULL OR valid_from <= valid_to) AND
(sys_to IS NULL OR sys_from <= sys_to)
),
CONSTRAINT chk_popularity_range CHECK (popularity_score >= 0.0 AND popularity_score <= 1.0),
CONSTRAINT chk_exploit_range CHECK (exploit_potential_score >= 0.0 AND exploit_potential_score <= 1.0),
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_score >= 0.0 AND uncertainty_score <= 1.0),
CONSTRAINT chk_centrality_range CHECK (centrality_score >= 0.0 AND centrality_score <= 1.0),
CONSTRAINT chk_staleness_range CHECK (staleness_score >= 0.0 AND staleness_score <= 1.0),
CONSTRAINT chk_composite_range CHECK (composite_score >= 0.0 AND composite_score <= 1.0)
);
-- ============================================================================
-- Step 4: Create indexes
-- ============================================================================
-- Ensure only one open unknown per subject per tenant (current valid time, current system time)
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
ON unknowns.unknown (tenant_id, subject_hash, kind)
WHERE valid_to IS NULL AND sys_to IS NULL;
-- Tenant-scoped queries (most common)
CREATE INDEX IF NOT EXISTS ix_unknown_tenant
ON unknowns.unknown (tenant_id);
-- Temporal query indexes for valid time
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_valid
ON unknowns.unknown (tenant_id, valid_from, valid_to);
-- Temporal query indexes for system time
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_sys
ON unknowns.unknown (tenant_id, sys_from, sys_to);
-- Current open unknowns by kind and severity
CREATE INDEX IF NOT EXISTS ix_unknown_tenant_kind_severity
ON unknowns.unknown (tenant_id, kind, severity)
WHERE valid_to IS NULL AND sys_to IS NULL;
-- Source correlation indexes
CREATE INDEX IF NOT EXISTS ix_unknown_source_scan
ON unknowns.unknown (source_scan_id)
WHERE source_scan_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_unknown_source_graph
ON unknowns.unknown (source_graph_id)
WHERE source_graph_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_unknown_source_sbom
ON unknowns.unknown (source_sbom_digest)
WHERE source_sbom_digest IS NOT NULL;
-- Context GIN index for JSONB queries
CREATE INDEX IF NOT EXISTS ix_unknown_context_gin
ON unknowns.unknown USING GIN (context jsonb_path_ops);
-- Subject lookup
CREATE INDEX IF NOT EXISTS ix_unknown_subject_ref
ON unknowns.unknown (tenant_id, subject_ref);
-- Unresolved unknowns
CREATE INDEX IF NOT EXISTS ix_unknown_unresolved
ON unknowns.unknown (tenant_id, kind, created_at DESC)
WHERE resolved_at IS NULL AND valid_to IS NULL AND sys_to IS NULL;
-- Band-based queries (most common for triage UI)
CREATE INDEX IF NOT EXISTS ix_unknown_triage_band
ON unknowns.unknown (tenant_id, triage_band, composite_score DESC)
WHERE sys_to = 'infinity'::timestamptz;
-- Hot band priority queue
CREATE INDEX IF NOT EXISTS ix_unknown_hot_queue
ON unknowns.unknown (tenant_id, composite_score DESC)
WHERE triage_band = 'hot' AND sys_to = 'infinity'::timestamptz;
-- Rescan scheduling
CREATE INDEX IF NOT EXISTS ix_unknown_rescan_schedule
ON unknowns.unknown (next_scheduled_rescan)
WHERE next_scheduled_rescan IS NOT NULL AND sys_to = 'infinity'::timestamptz;
-- Kind + band for dashboard aggregations
CREATE INDEX IF NOT EXISTS ix_unknown_kind_band
ON unknowns.unknown (tenant_id, kind, triage_band)
WHERE sys_to = 'infinity'::timestamptz;
-- GIN index for uncertainty flags queries
CREATE INDEX IF NOT EXISTS ix_unknown_uncertainty_flags
ON unknowns.unknown USING GIN (uncertainty_flags)
WHERE sys_to = 'infinity'::timestamptz;
-- ============================================================================
-- Step 5: Create views for common query patterns
-- ============================================================================
-- Current unknowns (valid now, known now)
CREATE OR REPLACE VIEW unknowns.current AS
SELECT * FROM unknowns.unknown
WHERE valid_to IS NULL AND sys_to IS NULL;
-- Resolved unknowns
CREATE OR REPLACE VIEW unknowns.resolved AS
SELECT * FROM unknowns.unknown
WHERE resolved_at IS NOT NULL;
-- Triage summary by band
CREATE OR REPLACE VIEW unknowns.triage_summary AS
SELECT
tenant_id,
triage_band,
kind::text AS unknown_kind,
COUNT(*) AS count,
AVG(composite_score) AS avg_score,
MAX(composite_score) AS max_score,
MIN(composite_score) AS min_score
FROM unknowns.unknown
WHERE sys_to = 'infinity'::timestamptz
AND resolved_at IS NULL
GROUP BY tenant_id, triage_band, kind;
-- ============================================================================
-- Step 6: Create temporal query functions
-- ============================================================================
-- Point-in-time query: What unknowns were valid at a given time?
CREATE OR REPLACE FUNCTION unknowns.as_of(
p_tenant_id TEXT,
p_valid_at TIMESTAMPTZ,
p_sys_at TIMESTAMPTZ DEFAULT NOW()
)
RETURNS SETOF unknowns.unknown
LANGUAGE sql STABLE
AS $$
SELECT * FROM unknowns.unknown
WHERE tenant_id = p_tenant_id
AND valid_from <= p_valid_at
AND (valid_to IS NULL OR valid_to > p_valid_at)
AND sys_from <= p_sys_at
AND (sys_to IS NULL OR sys_to > p_sys_at);
$$;
-- Count unknowns by kind for a tenant (current state)
CREATE OR REPLACE FUNCTION unknowns.count_by_kind(p_tenant_id TEXT)
RETURNS TABLE(kind unknowns.unknown_kind, count BIGINT)
LANGUAGE sql STABLE
AS $$
SELECT kind, count(*)
FROM unknowns.unknown
WHERE tenant_id = p_tenant_id
AND valid_to IS NULL
AND sys_to IS NULL
GROUP BY kind;
$$;
-- Count unknowns by severity for a tenant (current state)
CREATE OR REPLACE FUNCTION unknowns.count_by_severity(p_tenant_id TEXT)
RETURNS TABLE(severity unknowns.unknown_severity, count BIGINT)
LANGUAGE sql STABLE
AS $$
SELECT severity, count(*)
FROM unknowns.unknown
WHERE tenant_id = p_tenant_id
AND valid_to IS NULL
AND sys_to IS NULL
AND severity IS NOT NULL
GROUP BY severity;
$$;
-- ============================================================================
-- Step 7: Create scoring helper functions
-- ============================================================================
-- Calculate popularity score from deployment count
CREATE OR REPLACE FUNCTION unknowns.calc_popularity_score(p_deployment_count INT)
RETURNS FLOAT
LANGUAGE sql IMMUTABLE
AS $$
SELECT LEAST(1.0, LOG(1 + p_deployment_count) / LOG(1 + 100))::float;
$$;
-- Calculate staleness score from days since analysis
CREATE OR REPLACE FUNCTION unknowns.calc_staleness_score(p_days INT)
RETURNS FLOAT
LANGUAGE sql IMMUTABLE
AS $$
SELECT LEAST(1.0, p_days / 14.0)::float;
$$;
-- Assign triage band from composite score
CREATE OR REPLACE FUNCTION unknowns.assign_band(p_score FLOAT)
RETURNS unknowns.triage_band
LANGUAGE sql IMMUTABLE
AS $$
SELECT CASE
WHEN p_score >= 0.70 THEN 'hot'::unknowns.triage_band
WHEN p_score >= 0.40 THEN 'warm'::unknowns.triage_band
ELSE 'cold'::unknowns.triage_band
END;
$$;
-- Calculate composite score with default weights
-- wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
CREATE OR REPLACE FUNCTION unknowns.calc_composite_score(
p_popularity FLOAT,
p_exploit FLOAT,
p_uncertainty FLOAT,
p_centrality FLOAT,
p_staleness FLOAT,
p_weights JSONB DEFAULT '{"wP":0.25,"wE":0.25,"wU":0.25,"wC":0.15,"wS":0.10}'::jsonb
)
RETURNS FLOAT
LANGUAGE plpgsql IMMUTABLE
AS $$
DECLARE
v_wp FLOAT := COALESCE((p_weights->>'wP')::float, 0.25);
v_we FLOAT := COALESCE((p_weights->>'wE')::float, 0.25);
v_wu FLOAT := COALESCE((p_weights->>'wU')::float, 0.25);
v_wc FLOAT := COALESCE((p_weights->>'wC')::float, 0.15);
v_ws FLOAT := COALESCE((p_weights->>'wS')::float, 0.10);
v_score FLOAT;
BEGIN
v_score := v_wp * COALESCE(p_popularity, 0) +
v_we * COALESCE(p_exploit, 0) +
v_wu * COALESCE(p_uncertainty, 0) +
v_wc * COALESCE(p_centrality, 0) +
v_ws * COALESCE(p_staleness, 0);
RETURN LEAST(1.0, GREATEST(0.0, v_score));
END;
$$;
-- ============================================================================
-- Step 8: Enable RLS
-- ============================================================================
ALTER TABLE unknowns.unknown ENABLE ROW LEVEL SECURITY;
ALTER TABLE unknowns.unknown FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS unknown_tenant_isolation ON unknowns.unknown;
CREATE POLICY unknown_tenant_isolation ON unknowns.unknown
FOR ALL
USING (tenant_id = unknowns_app.require_current_tenant())
WITH CHECK (tenant_id = unknowns_app.require_current_tenant());
-- Admin bypass role
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'unknowns_admin') THEN
CREATE ROLE unknowns_admin WITH NOLOGIN BYPASSRLS;
END IF;
END
$$;
-- ============================================================================
-- Step 9: Create update trigger for updated_at
-- ============================================================================
CREATE OR REPLACE FUNCTION unknowns.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_unknown_updated_at ON unknowns.unknown;
CREATE TRIGGER trg_unknown_updated_at
BEFORE UPDATE ON unknowns.unknown
FOR EACH ROW EXECUTE FUNCTION unknowns.update_updated_at();
-- ============================================================================
-- Step 10: Add column comments
-- ============================================================================
COMMENT ON COLUMN unknowns.unknown.popularity_score IS
'Popularity impact (P). Formula: min(1, log10(1+deployments)/log10(101)). Range [0,1].';
COMMENT ON COLUMN unknowns.unknown.exploit_potential_score IS
'Exploit consequence potential (E). Based on CVE severity + KEV status. Range [0,1].';
COMMENT ON COLUMN unknowns.unknown.uncertainty_score IS
'Uncertainty density (U). Aggregated from flags. Range [0,1].';
COMMENT ON COLUMN unknowns.unknown.centrality_score IS
'Graph centrality (C). Normalized betweenness centrality. Range [0,1].';
COMMENT ON COLUMN unknowns.unknown.staleness_score IS
'Evidence staleness (S). Formula: min(1, age_days/14). Range [0,1].';
COMMENT ON COLUMN unknowns.unknown.composite_score IS
'Weighted composite: clamp01(0.25*P + 0.25*E + 0.25*U + 0.15*C + 0.10*S).';
COMMENT ON COLUMN unknowns.unknown.triage_band IS
'HOT (>=0.70): immediate rescan. WARM (0.40-0.69): 12-72h. COLD (<0.40): weekly.';
COMMENT ON COLUMN unknowns.unknown.scoring_trace IS
'JSONB trace of scoring computation for audit/debugging.';
COMMENT ON FUNCTION unknowns.calc_composite_score IS
'Calculate weighted composite score. Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10.';
COMMIT;
-- ============================================================================
-- VERIFICATION (run manually)
-- ============================================================================
-- SELECT
-- id,
-- kind::text,
-- triage_band::text,
-- composite_score,
-- popularity_score,
-- exploit_potential_score,
-- uncertainty_score,
-- centrality_score,
-- staleness_score
-- FROM unknowns.unknown
-- WHERE sys_to = 'infinity'::timestamptz
-- ORDER BY composite_score DESC
-- LIMIT 10;

View File

@@ -3,7 +3,7 @@ using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Persistence;
using StellaOps.Unknowns.Core.Repositories;
namespace StellaOps.Unknowns.Storage.Postgres.Persistence;
namespace StellaOps.Unknowns.Persistence.Postgres;
/// <summary>
/// PostgreSQL implementation of the unknown persister.

View File

@@ -7,7 +7,7 @@ using NpgsqlTypes;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
namespace StellaOps.Unknowns.Storage.Postgres.Repositories;
namespace StellaOps.Unknowns.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the bitemporal unknowns repository.
@@ -803,9 +803,15 @@ public sealed class PostgresUnknownRepository : IUnknownRepository
private static Unknown MapUnknown(NpgsqlDataReader reader)
{
// Column indices match SelectColumns order:
// 0-10: id, tenant_id, subject_hash, subject_type, subject_ref, kind, severity, context, source_scan_id, source_graph_id, source_sbom_digest
// 11-21: valid_from, valid_to, sys_from, sys_to, resolved_at, resolution_type, resolution_ref, resolution_notes, created_at, created_by, updated_at
// 22-26: popularity_score, deployment_count, exploit_potential_score, uncertainty_score, uncertainty_flags
// 27-34: centrality_score, degree_centrality, betweenness_centrality, staleness_score, days_since_analysis, composite_score, triage_band, scoring_trace
// 35-40: rescan_attempts, last_rescan_result, next_scheduled_rescan, last_analyzed_at, evidence_set_hash, graph_slice_hash
var contextJson = reader.IsDBNull(7) ? null : reader.GetFieldValue<string>(7);
var uncertaintyFlagsJson = reader.IsDBNull(25) ? null : reader.GetFieldValue<string>(25);
var scoringTraceJson = reader.IsDBNull(33) ? null : reader.GetFieldValue<string>(33);
var uncertaintyFlagsJson = reader.IsDBNull(26) ? null : reader.GetFieldValue<string>(26);
var scoringTraceJson = reader.IsDBNull(34) ? null : reader.GetFieldValue<string>(34);
return new Unknown
{
@@ -831,7 +837,7 @@ public sealed class PostgresUnknownRepository : IUnknownRepository
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
CreatedBy = reader.GetString(20),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(21),
// Scoring fields
// Scoring fields (indices 22-40)
PopularityScore = reader.IsDBNull(22) ? 0.0 : reader.GetDouble(22),
DeploymentCount = reader.IsDBNull(23) ? 0 : reader.GetInt32(23),
ExploitPotentialScore = reader.IsDBNull(24) ? 0.0 : reader.GetDouble(24),

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Unknowns.Persistence</RootNamespace>
<AssemblyName>StellaOps.Unknowns.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps Unknowns module (EF Core + Raw SQL + InMemory)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
<!-- Embed SQL migrations as resources -->
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>
</Project>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Unknowns.Storage.Postgres</RootNamespace>
<Description>PostgreSQL storage implementation for the StellaOps Unknowns module (bitemporal ambiguity tracking)</Description>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -33,7 +33,7 @@ public class UnknownRankerTests
var score = _ranker.Rank(blast, scarcity: 0.8, pressure, containment);
// Assert - should be very high (close to 1.0)
score.Should().BeGreaterOrEqualTo(0.8);
score.Should().BeGreaterThanOrEqualTo(0.8);
}
[Fact]
@@ -66,7 +66,7 @@ public class UnknownRankerTests
// Assert - containment should reduce score
scoreWellContained.Should().BeLessThan(scoreNoContainment);
(scoreNoContainment - scoreWellContained).Should().BeGreaterOrEqualTo(0.15); // At least 0.15 reduction
(scoreNoContainment - scoreWellContained).Should().BeGreaterThanOrEqualTo(0.15); // At least 0.15 reduction
}
#endregion
@@ -109,8 +109,8 @@ public class UnknownRankerTests
var deduction = containment.Deduction();
// Assert - should be significant
deduction.Should().BeGreaterOrEqualTo(0.25);
deduction.Should().BeLessOrEqualTo(0.30);
deduction.Should().BeGreaterThanOrEqualTo(0.25);
deduction.Should().BeLessThanOrEqualTo(0.30);
}
[Fact]
@@ -297,7 +297,7 @@ public class UnknownRankerTests
var score = _ranker.Rank(blast, scarcity: 0, pressure, containment);
// Assert - should be clamped to 0, not negative
score.Should().BeGreaterOrEqualTo(0);
score.Should().BeGreaterThanOrEqualTo(0);
}
#endregion

View File

@@ -12,23 +12,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.Extensions.Time.Testing" Version="10.0.0" />
</ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Unknowns.Core/StellaOps.Unknowns.Core.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -2,13 +2,13 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Storage.Postgres.Repositories;
using StellaOps.Unknowns.Persistence.Postgres.Repositories;
using Testcontainers.PostgreSql;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Unknowns.Storage.Postgres.Tests;
namespace StellaOps.Unknowns.Persistence.Tests;
public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
{
@@ -96,6 +96,11 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
EXCEPTION WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE unknowns.triage_band AS ENUM ('hot', 'warm', 'cold');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
CREATE TABLE IF NOT EXISTS unknowns.unknown (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
@@ -118,7 +123,27 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
resolution_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL DEFAULT 'system',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Scoring columns (from 002_scoring_extension.sql)
popularity_score FLOAT DEFAULT 0.0,
deployment_count INT DEFAULT 0,
exploit_potential_score FLOAT DEFAULT 0.0,
uncertainty_score FLOAT DEFAULT 0.0,
uncertainty_flags JSONB DEFAULT '{}'::jsonb,
centrality_score FLOAT DEFAULT 0.0,
degree_centrality INT DEFAULT 0,
betweenness_centrality FLOAT DEFAULT 0.0,
staleness_score FLOAT DEFAULT 0.0,
days_since_analysis INT DEFAULT 0,
composite_score FLOAT DEFAULT 0.0,
triage_band unknowns.triage_band DEFAULT 'cold',
scoring_trace JSONB,
rescan_attempts INT DEFAULT 0,
last_rescan_result TEXT,
next_scheduled_rescan TIMESTAMPTZ,
last_analyzed_at TIMESTAMPTZ,
evidence_set_hash BYTEA,
graph_slice_hash BYTEA
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
@@ -127,7 +152,6 @@ public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
""";
await using var command = new NpgsqlCommand(schema, connection);
using StellaOps.TestKit;
await command.ExecuteNonQueryAsync();
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Unknowns.Persistence.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Unknowns.Persistence\StellaOps.Unknowns.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Unknowns.Storage.Postgres.Tests</RootNamespace>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Unknowns.Storage.Postgres\StellaOps.Unknowns.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>