This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

136
src/Unknowns/AGENTS.md Normal file
View File

@@ -0,0 +1,136 @@
# Unknowns Module - Agent Guidelines
## Module Overview
The Unknowns module tracks ambiguities discovered during vulnerability scanning that cannot be definitively resolved. It uses **bitemporal semantics** to enable point-in-time compliance queries and full audit trails.
## Key Concepts
### Bitemporal Data Model
The module uses two time dimensions:
- **Valid time** (`valid_from`, `valid_to`): When the unknown was relevant in the real world
- **System time** (`sys_from`, `sys_to`): When the system recorded/knew about the unknown
This enables queries like:
- "What unknowns existed on January 1st?" (valid time query)
- "What did we know about unknowns on January 1st, as of January 15th?" (bitemporal query)
### Unknown Types
| Kind | Description |
|------|-------------|
| `MissingSbom` | SBOM is missing for a component |
| `AmbiguousPackage` | Package reference is ambiguous |
| `MissingFeed` | No vulnerability feed covers this component |
| `UnresolvedEdge` | Dependency edge cannot be resolved |
| `NoVersionInfo` | No version information available |
| `UnknownEcosystem` | Ecosystem is not recognized |
| `PartialMatch` | Only partial match found |
| `VersionRangeUnbounded` | Version range is unbounded |
| `UnsupportedFormat` | Format is not supported |
| `TransitiveGap` | Gap in transitive dependency chain |
### Resolution Types
| Type | Description |
|------|-------------|
| `FeedUpdated` | Vulnerability feed was updated to cover the component |
| `SbomProvided` | SBOM was provided for the component |
| `ManualMapping` | Manual mapping was created |
| `Superseded` | Superseded by another unknown or finding |
| `FalsePositive` | Determined to be a false positive |
| `WontFix` | Acknowledged but not going to be fixed |
## Directory Structure
```
src/Unknowns/
├── __Libraries/
│ ├── StellaOps.Unknowns.Core/
│ │ ├── Models/
│ │ │ └── Unknown.cs # Domain model and enums
│ │ └── Repositories/
│ │ └── IUnknownRepository.cs # Repository interface
│ └── StellaOps.Unknowns.Storage.Postgres/
│ ├── Migrations/
│ │ └── 001_initial_schema.sql # Bitemporal schema
│ └── Repositories/
│ └── PostgresUnknownRepository.cs
└── __Tests/
└── StellaOps.Unknowns.Storage.Postgres.Tests/
└── PostgresUnknownRepositoryTests.cs
```
## Database Schema
### Key Tables
- `unknowns.unknown` - Main bitemporal table with RLS
- Views: `unknowns.current`, `unknowns.resolved`
- Functions: `unknowns.as_of()`, `unknowns.count_by_kind()`, `unknowns.count_by_severity()`
### Row-Level Security
The module uses RLS for tenant isolation:
```sql
SELECT set_config('app.tenant_id', 'my-tenant', false);
SELECT * FROM unknowns.unknown; -- Only returns rows for my-tenant
```
## Coding Guidelines
### Creating Unknowns
```csharp
var unknown = await repository.CreateAsync(
tenantId: "my-tenant",
subjectType: UnknownSubjectType.Package,
subjectRef: "pkg:npm/lodash@4.17.21",
kind: UnknownKind.MissingFeed,
severity: UnknownSeverity.Medium,
context: """{"ecosystem": "npm"}""",
sourceScanId: scanId,
sourceGraphId: null,
sourceSbomDigest: null,
createdBy: "scanner",
cancellationToken);
```
### Resolving Unknowns
```csharp
var resolved = await repository.ResolveAsync(
tenantId,
unknownId,
ResolutionType.FeedUpdated,
resolutionRef: "nvd-feed-2025-01",
resolutionNotes: "NVD now covers this package",
resolvedBy: "feed-sync",
cancellationToken);
```
### Point-in-Time Queries
```csharp
// What unknowns existed on a specific date?
var historicalUnknowns = await repository.AsOfAsync(
tenantId,
validAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
cancellationToken: ct);
```
## Testing
Run tests with:
```bash
dotnet test src/Unknowns/__Tests/StellaOps.Unknowns.Storage.Postgres.Tests
```
Tests use Testcontainers for PostgreSQL integration testing.
## Related Documentation
- `docs/operations/postgresql-patterns-runbook.md` - Operational guide
- `docs/implplan/SPRINT_3420_0001_0001_bitemporal_unknowns_schema.md` - Sprint spec
- `deploy/postgres-validation/001_validate_rls.sql` - RLS validation

View File

@@ -0,0 +1,266 @@
using System.Text.Json;
namespace StellaOps.Unknowns.Core.Models;
/// <summary>
/// Represents an unknown - an ambiguity discovered during vulnerability scanning
/// that cannot be definitively resolved. Uses bitemporal semantics for audit trails.
/// </summary>
public sealed record Unknown
{
/// <summary>Unique identifier.</summary>
public required Guid Id { get; init; }
/// <summary>Tenant that owns this unknown.</summary>
public required string TenantId { get; init; }
/// <summary>SHA-256 hash of the subject (for deduplication).</summary>
public required string SubjectHash { get; init; }
/// <summary>Type of subject (package, ecosystem, version, etc.).</summary>
public required UnknownSubjectType SubjectType { get; init; }
/// <summary>Human-readable reference (purl, name, path).</summary>
public required string SubjectRef { get; init; }
/// <summary>Classification of the unknown.</summary>
public required UnknownKind Kind { get; init; }
/// <summary>Severity assessment of the unknown.</summary>
public UnknownSeverity? Severity { get; init; }
/// <summary>Additional context as flexible JSON.</summary>
public JsonDocument? Context { get; init; }
/// <summary>ID of the scan that discovered this unknown.</summary>
public Guid? SourceScanId { get; init; }
/// <summary>ID of the graph revision context.</summary>
public Guid? SourceGraphId { get; init; }
/// <summary>SBOM digest if applicable.</summary>
public string? SourceSbomDigest { get; init; }
// Bitemporal columns
/// <summary>When the unknown became valid in the real world.</summary>
public required DateTimeOffset ValidFrom { get; init; }
/// <summary>When the unknown ceased to be valid (null = still valid).</summary>
public DateTimeOffset? ValidTo { get; init; }
/// <summary>When the system recorded this state.</summary>
public required DateTimeOffset SysFrom { get; init; }
/// <summary>When this system state was superseded (null = current).</summary>
public DateTimeOffset? SysTo { get; init; }
// Resolution tracking
/// <summary>When the unknown was resolved.</summary>
public DateTimeOffset? ResolvedAt { get; init; }
/// <summary>How the unknown was resolved.</summary>
public ResolutionType? ResolutionType { get; init; }
/// <summary>Reference to the resolving entity.</summary>
public string? ResolutionRef { get; init; }
/// <summary>Notes about the resolution.</summary>
public string? ResolutionNotes { get; init; }
// Scoring (Triage)
/// <summary>Popularity impact score (P). Range [0,1]. Based on deployment count.</summary>
public double PopularityScore { get; init; }
/// <summary>Number of deployments referencing this unknown's subject.</summary>
public int DeploymentCount { get; init; }
/// <summary>Exploit consequence potential (E). Range [0,1]. Based on CVE severity + KEV.</summary>
public double ExploitPotentialScore { get; init; }
/// <summary>Uncertainty density (U). Range [0,1]. Aggregated from flags.</summary>
public double UncertaintyScore { get; init; }
/// <summary>Uncertainty flags as flexible JSON.</summary>
public JsonDocument? UncertaintyFlags { get; init; }
/// <summary>Graph centrality (C). Range [0,1]. Normalized betweenness centrality.</summary>
public double CentralityScore { get; init; }
/// <summary>Degree centrality (in + out edges).</summary>
public int DegreeCentrality { get; init; }
/// <summary>Betweenness centrality (raw).</summary>
public double BetweennessCentrality { get; init; }
/// <summary>Evidence staleness (S). Range [0,1]. Based on days since analysis.</summary>
public double StalenessScore { get; init; }
/// <summary>Days since last analysis.</summary>
public int DaysSinceAnalysis { get; init; }
/// <summary>Weighted composite score. Range [0,1].</summary>
public double CompositeScore { get; init; }
/// <summary>Triage band assignment: hot, warm, cold.</summary>
public TriageBand TriageBand { get; init; }
/// <summary>Scoring computation trace for audit/debugging.</summary>
public JsonDocument? ScoringTrace { get; init; }
// Rescan Scheduling
/// <summary>Number of rescan attempts.</summary>
public int RescanAttempts { get; init; }
/// <summary>Result of last rescan attempt.</summary>
public string? LastRescanResult { get; init; }
/// <summary>Next scheduled rescan time.</summary>
public DateTimeOffset? NextScheduledRescan { get; init; }
/// <summary>When this unknown was last analyzed.</summary>
public DateTimeOffset? LastAnalyzedAt { get; init; }
// Evidence Hashes
/// <summary>SHA-256 hash of the evidence set for deterministic replay.</summary>
public byte[]? EvidenceSetHash { get; init; }
/// <summary>SHA-256 hash of the graph slice for deterministic replay.</summary>
public byte[]? GraphSliceHash { get; init; }
// Audit
/// <summary>When this record was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Who created this record.</summary>
public required string CreatedBy { get; init; }
/// <summary>When this record was last updated.</summary>
public DateTimeOffset UpdatedAt { get; init; }
// Computed properties
/// <summary>Whether this unknown is currently open (valid and not superseded).</summary>
public bool IsOpen => ValidTo is null && SysTo is null;
/// <summary>Whether this unknown has been resolved.</summary>
public bool IsResolved => ResolvedAt is not null;
/// <summary>Whether this unknown is current (not superseded in system time).</summary>
public bool IsCurrent => SysTo is null;
}
/// <summary>Type of subject that has an unknown.</summary>
public enum UnknownSubjectType
{
/// <summary>A package (identified by purl or name).</summary>
Package,
/// <summary>An ecosystem (npm, maven, etc.).</summary>
Ecosystem,
/// <summary>A version specification.</summary>
Version,
/// <summary>An edge in the SBOM dependency graph.</summary>
SbomEdge,
/// <summary>A file or binary.</summary>
File,
/// <summary>A runtime component.</summary>
Runtime
}
/// <summary>Classification of the unknown.</summary>
public enum UnknownKind
{
/// <summary>SBOM is missing for a component.</summary>
MissingSbom,
/// <summary>Package reference is ambiguous.</summary>
AmbiguousPackage,
/// <summary>No vulnerability feed covers this component.</summary>
MissingFeed,
/// <summary>Dependency edge cannot be resolved.</summary>
UnresolvedEdge,
/// <summary>No version information available.</summary>
NoVersionInfo,
/// <summary>Ecosystem is not recognized.</summary>
UnknownEcosystem,
/// <summary>Only partial match found.</summary>
PartialMatch,
/// <summary>Version range is unbounded.</summary>
VersionRangeUnbounded,
/// <summary>Format is not supported.</summary>
UnsupportedFormat,
/// <summary>Gap in transitive dependency chain.</summary>
TransitiveGap
}
/// <summary>Severity of the unknown's impact.</summary>
public enum UnknownSeverity
{
/// <summary>Critical impact - blocks security assessment.</summary>
Critical,
/// <summary>High impact - significant blind spot.</summary>
High,
/// <summary>Medium impact - notable gap.</summary>
Medium,
/// <summary>Low impact - minor gap.</summary>
Low,
/// <summary>Informational only.</summary>
Info
}
/// <summary>How the unknown was resolved.</summary>
public enum ResolutionType
{
/// <summary>Vulnerability feed was updated to cover the component.</summary>
FeedUpdated,
/// <summary>SBOM was provided for the component.</summary>
SbomProvided,
/// <summary>Manual mapping was created.</summary>
ManualMapping,
/// <summary>Superseded by another unknown or finding.</summary>
Superseded,
/// <summary>Determined to be a false positive.</summary>
FalsePositive,
/// <summary>Acknowledged but not going to be fixed.</summary>
WontFix
}
/// <summary>Triage band for unknown prioritization.</summary>
public enum TriageBand
{
/// <summary>HOT (score >= 0.70): Immediate rescan required.</summary>
Hot,
/// <summary>WARM (score 0.40-0.69): Scheduled rescan within 12-72 hours.</summary>
Warm,
/// <summary>COLD (score < 0.40): Weekly batch processing.</summary>
Cold
}

View File

@@ -0,0 +1,206 @@
using StellaOps.Unknowns.Core.Models;
namespace StellaOps.Unknowns.Core.Repositories;
/// <summary>
/// Repository interface for bitemporal unknowns operations.
/// </summary>
public interface IUnknownRepository
{
/// <summary>
/// Creates a new unknown record.
/// </summary>
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);
/// <summary>
/// Gets an unknown by ID.
/// </summary>
Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken);
/// <summary>
/// Gets an unknown by subject hash (current state only).
/// </summary>
Task<Unknown?> GetBySubjectHashAsync(string tenantId, string subjectHash, UnknownKind kind, CancellationToken cancellationToken);
/// <summary>
/// Gets all current open unknowns for a tenant.
/// </summary>
Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
string tenantId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets current open unknowns filtered by kind.
/// </summary>
Task<IReadOnlyList<Unknown>> GetByKindAsync(
string tenantId,
UnknownKind kind,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets current open unknowns filtered by severity.
/// </summary>
Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
string tenantId,
UnknownSeverity severity,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets unknowns associated with a specific scan.
/// </summary>
Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
string tenantId,
Guid scanId,
CancellationToken cancellationToken);
/// <summary>
/// Bitemporal query: Gets unknowns as they were at a specific point in time.
/// </summary>
Task<IReadOnlyList<Unknown>> AsOfAsync(
string tenantId,
DateTimeOffset validAt,
DateTimeOffset? systemAt = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves an unknown with the specified resolution details.
/// </summary>
Task<Unknown> ResolveAsync(
string tenantId,
Guid id,
ResolutionType resolutionType,
string? resolutionRef,
string? resolutionNotes,
string resolvedBy,
CancellationToken cancellationToken);
/// <summary>
/// Supersedes an unknown (closes valid time but keeps system record).
/// </summary>
Task SupersedeAsync(
string tenantId,
Guid id,
string supersededBy,
CancellationToken cancellationToken);
/// <summary>
/// Counts unknowns grouped by kind for a tenant.
/// </summary>
Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Counts unknowns grouped by severity for a tenant.
/// </summary>
Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Gets total count of open unknowns for a tenant.
/// </summary>
Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken);
// Triage methods
/// <summary>
/// Gets unknowns by triage band, ordered by composite score descending.
/// </summary>
Task<IReadOnlyList<Unknown>> GetByTriageBandAsync(
string tenantId,
TriageBand band,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all HOT unknowns for immediate processing.
/// </summary>
Task<IReadOnlyList<Unknown>> GetHotQueueAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets unknowns due for rescan based on next_scheduled_rescan.
/// </summary>
Task<IReadOnlyList<Unknown>> GetDueForRescanAsync(
string tenantId,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates scoring for an unknown.
/// </summary>
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);
/// <summary>
/// Records a rescan attempt result.
/// </summary>
Task<Unknown> RecordRescanAttemptAsync(
string tenantId,
Guid id,
string result,
DateTimeOffset? nextRescan,
CancellationToken cancellationToken);
/// <summary>
/// Counts unknowns grouped by triage band.
/// </summary>
Task<IReadOnlyDictionary<TriageBand, long>> CountByTriageBandAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Gets triage summary statistics for a tenant.
/// </summary>
Task<IReadOnlyList<TriageSummary>> GetTriageSummaryAsync(
string tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Summary of unknowns by triage band and kind.
/// </summary>
public sealed record TriageSummary
{
public required TriageBand Band { get; init; }
public required UnknownKind Kind { get; init; }
public required long Count { get; init; }
public required double AvgScore { get; init; }
public required double MaxScore { get; init; }
public required double MinScore { get; init; }
}

View File

@@ -0,0 +1,15 @@
<?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.Core</RootNamespace>
<Description>Core domain models and abstractions for the StellaOps Unknowns module (bitemporal ambiguity tracking)</Description>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,307 @@
-- Unknowns Schema Migration 001: Initial Bitemporal Schema
-- Sprint: SPRINT_3420_0001_0001 - Bitemporal Unknowns Schema
-- 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.
--
-- 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
BEGIN;
-- ============================================================================
-- Step 1: Create helper schema for app functions
-- ============================================================================
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 $$;
-- ============================================================================
-- Step 3: Create main bitemporal unknowns table
-- ============================================================================
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,
-- 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)
)
);
-- ============================================================================
-- 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;
-- ============================================================================
-- 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;
-- ============================================================================
-- 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: 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 8: 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();
COMMIT;

View File

@@ -0,0 +1,233 @@
-- Unknowns Schema Migration 002: Scoring Extension for Triage
-- Sprint: SPRINT_1102_0001_0001 / SPRINT_3600_0001_0001 - Triage & Unknowns
-- Category: A (safe, can run at startup)
--
-- Purpose: Add scoring columns to enable intelligent triage of scan ambiguities
-- with HOT/WARM/COLD band assignment and rescan scheduling.
--
-- Reference: 14-Dec-2025 - Triage and Unknowns Technical Reference §17-18
BEGIN;
-- ============================================================================
-- SCORING ENUM
-- ============================================================================
CREATE TYPE unknowns.triage_band AS ENUM ('hot', 'warm', 'cold');
-- ============================================================================
-- SCORING COLUMNS
-- ============================================================================
ALTER TABLE unknowns.unknown
-- Popularity score (P): Based on deployment count
-- Formula: min(1, log10(1 + deployments) / log10(1 + 100))
ADD COLUMN IF NOT EXISTS popularity_score FLOAT DEFAULT 0.0
CONSTRAINT chk_popularity_range CHECK (popularity_score >= 0.0 AND popularity_score <= 1.0),
ADD COLUMN IF NOT EXISTS deployment_count INT DEFAULT 0,
-- Exploit potential score (E): Based on CVE severity if known
-- Range: 0.0 (no known CVE) to 1.0 (critical CVE + KEV)
ADD COLUMN IF NOT EXISTS exploit_potential_score FLOAT DEFAULT 0.0
CONSTRAINT chk_exploit_range CHECK (exploit_potential_score >= 0.0 AND exploit_potential_score <= 1.0),
-- Uncertainty density score (U): Aggregated from flags
-- Flags: no_provenance(0.30), version_range(0.25), conflicting_feeds(0.20),
-- missing_vector(0.15), unreachable_source(0.10)
ADD COLUMN IF NOT EXISTS uncertainty_score FLOAT DEFAULT 0.0
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_score >= 0.0 AND uncertainty_score <= 1.0),
ADD COLUMN IF NOT EXISTS uncertainty_flags JSONB DEFAULT '{}'::jsonb,
-- Centrality score (C): Graph position importance
-- Based on normalized betweenness centrality
ADD COLUMN IF NOT EXISTS centrality_score FLOAT DEFAULT 0.0
CONSTRAINT chk_centrality_range CHECK (centrality_score >= 0.0 AND centrality_score <= 1.0),
ADD COLUMN IF NOT EXISTS degree_centrality INT DEFAULT 0,
ADD COLUMN IF NOT EXISTS betweenness_centrality FLOAT DEFAULT 0.0,
-- Staleness score (S): Evidence age
-- Formula: min(1, age_days / 14)
ADD COLUMN IF NOT EXISTS staleness_score FLOAT DEFAULT 0.0
CONSTRAINT chk_staleness_range CHECK (staleness_score >= 0.0 AND staleness_score <= 1.0),
ADD COLUMN IF NOT EXISTS days_since_analysis INT DEFAULT 0,
-- Composite score: Weighted sum of factors
-- Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10
ADD COLUMN IF NOT EXISTS composite_score FLOAT DEFAULT 0.0
CONSTRAINT chk_composite_range CHECK (composite_score >= 0.0 AND composite_score <= 1.0),
-- Triage band: HOT (>=0.70), WARM (0.40-0.69), COLD (<0.40)
ADD COLUMN IF NOT EXISTS triage_band unknowns.triage_band DEFAULT 'cold',
-- Normalization trace for audit/debugging
ADD COLUMN IF NOT EXISTS scoring_trace JSONB,
-- Rescan scheduling
ADD COLUMN IF NOT EXISTS rescan_attempts INT DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_rescan_result TEXT,
ADD COLUMN IF NOT EXISTS next_scheduled_rescan TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_analyzed_at TIMESTAMPTZ,
-- Evidence hashes for deterministic replay
ADD COLUMN IF NOT EXISTS evidence_set_hash BYTEA,
ADD COLUMN IF NOT EXISTS graph_slice_hash BYTEA;
-- ============================================================================
-- INDEXES FOR TRIAGE QUERIES
-- ============================================================================
-- 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;
-- ============================================================================
-- 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;
$$;
-- ============================================================================
-- VIEW: Unknowns by triage band with counts
-- ============================================================================
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;
-- ============================================================================
-- 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

@@ -0,0 +1,660 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
namespace StellaOps.Unknowns.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the bitemporal unknowns repository.
/// </summary>
public sealed class PostgresUnknownRepository : IUnknownRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresUnknownRepository> _logger;
private readonly int _commandTimeoutSeconds;
private const string SelectColumns = """
id, tenant_id, subject_hash, subject_type::text, subject_ref,
kind::text, severity::text, context, source_scan_id, source_graph_id, source_sbom_digest,
valid_from, valid_to, sys_from, sys_to,
resolved_at, resolution_type::text, resolution_ref, resolution_notes,
created_at, created_by, updated_at
""";
public PostgresUnknownRepository(
NpgsqlDataSource dataSource,
ILogger<PostgresUnknownRepository> logger,
int commandTimeoutSeconds = 30)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeoutSeconds = commandTimeoutSeconds;
}
public async 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)
{
var id = Guid.NewGuid();
var subjectHash = ComputeSubjectHash(subjectRef);
var now = DateTimeOffset.UtcNow;
const string sql = """
INSERT INTO unknowns.unknown (
id, tenant_id, subject_hash, subject_type, subject_ref,
kind, severity, context, source_scan_id, source_graph_id, source_sbom_digest,
valid_from, sys_from, created_at, created_by, updated_at
) VALUES (
@id, @tenant_id, @subject_hash, @subject_type::unknowns.subject_type, @subject_ref,
@kind::unknowns.unknown_kind, @severity::unknowns.unknown_severity, @context::jsonb,
@source_scan_id, @source_graph_id, @source_sbom_digest,
@valid_from, @sys_from, @created_at, @created_by, @updated_at
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("subject_hash", subjectHash);
command.Parameters.AddWithValue("subject_type", MapSubjectType(subjectType));
command.Parameters.AddWithValue("subject_ref", subjectRef);
command.Parameters.AddWithValue("kind", MapUnknownKind(kind));
command.Parameters.AddWithValue("severity", severity.HasValue ? MapSeverity(severity.Value) : DBNull.Value);
command.Parameters.Add(new NpgsqlParameter("context", NpgsqlDbType.Jsonb)
{
Value = context ?? "{}"
});
command.Parameters.AddWithValue("source_scan_id", sourceScanId.HasValue ? sourceScanId.Value : DBNull.Value);
command.Parameters.AddWithValue("source_graph_id", sourceGraphId.HasValue ? sourceGraphId.Value : DBNull.Value);
command.Parameters.AddWithValue("source_sbom_digest", (object?)sourceSbomDigest ?? DBNull.Value);
command.Parameters.AddWithValue("valid_from", now);
command.Parameters.AddWithValue("sys_from", now);
command.Parameters.AddWithValue("created_at", now);
command.Parameters.AddWithValue("created_by", createdBy);
command.Parameters.AddWithValue("updated_at", now);
await command.ExecuteNonQueryAsync(cancellationToken);
_logger.LogDebug("Created unknown {Id} for tenant {TenantId}, kind={Kind}", id, tenantId, kind);
return new Unknown
{
Id = id,
TenantId = tenantId,
SubjectHash = subjectHash,
SubjectType = subjectType,
SubjectRef = subjectRef,
Kind = kind,
Severity = severity,
Context = context is not null ? JsonDocument.Parse(context) : null,
SourceScanId = sourceScanId,
SourceGraphId = sourceGraphId,
SourceSbomDigest = sourceSbomDigest,
ValidFrom = now,
ValidTo = null,
SysFrom = now,
SysTo = null,
ResolvedAt = null,
ResolutionType = null,
ResolutionRef = null,
ResolutionNotes = null,
CreatedAt = now,
CreatedBy = createdBy,
UpdatedAt = now
};
}
public async Task<Unknown?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken)
{
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id AND id = @id AND sys_to IS NULL
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return MapUnknown(reader);
}
public async Task<Unknown?> GetBySubjectHashAsync(
string tenantId,
string subjectHash,
UnknownKind kind,
CancellationToken cancellationToken)
{
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND subject_hash = @subject_hash
AND kind = @kind::unknowns.unknown_kind
AND valid_to IS NULL
AND sys_to IS NULL
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("subject_hash", subjectHash);
command.Parameters.AddWithValue("kind", MapUnknownKind(kind));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return MapUnknown(reader);
}
public async Task<IReadOnlyList<Unknown>> GetOpenUnknownsAsync(
string tenantId,
int? limit = null,
int? offset = null,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND valid_to IS NULL
AND sys_to IS NULL
ORDER BY created_at DESC
{(limit.HasValue ? "LIMIT @limit" : "")}
{(offset.HasValue ? "OFFSET @offset" : "")}
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
if (limit.HasValue)
command.Parameters.AddWithValue("limit", limit.Value);
if (offset.HasValue)
command.Parameters.AddWithValue("offset", offset.Value);
return await ReadUnknownsAsync(command, cancellationToken);
}
public async Task<IReadOnlyList<Unknown>> GetByKindAsync(
string tenantId,
UnknownKind kind,
int? limit = null,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND kind = @kind::unknowns.unknown_kind
AND valid_to IS NULL
AND sys_to IS NULL
ORDER BY created_at DESC
{(limit.HasValue ? "LIMIT @limit" : "")}
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("kind", MapUnknownKind(kind));
if (limit.HasValue)
command.Parameters.AddWithValue("limit", limit.Value);
return await ReadUnknownsAsync(command, cancellationToken);
}
public async Task<IReadOnlyList<Unknown>> GetBySeverityAsync(
string tenantId,
UnknownSeverity severity,
int? limit = null,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND severity = @severity::unknowns.unknown_severity
AND valid_to IS NULL
AND sys_to IS NULL
ORDER BY created_at DESC
{(limit.HasValue ? "LIMIT @limit" : "")}
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("severity", MapSeverity(severity));
if (limit.HasValue)
command.Parameters.AddWithValue("limit", limit.Value);
return await ReadUnknownsAsync(command, cancellationToken);
}
public async Task<IReadOnlyList<Unknown>> GetByScanIdAsync(
string tenantId,
Guid scanId,
CancellationToken cancellationToken)
{
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND source_scan_id = @scan_id
AND sys_to IS NULL
ORDER BY created_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("scan_id", scanId);
return await ReadUnknownsAsync(command, cancellationToken);
}
public async Task<IReadOnlyList<Unknown>> AsOfAsync(
string tenantId,
DateTimeOffset validAt,
DateTimeOffset? systemAt = null,
CancellationToken cancellationToken = default)
{
var sysAt = systemAt ?? DateTimeOffset.UtcNow;
var sql = $"""
SELECT {SelectColumns}
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND valid_from <= @valid_at
AND (valid_to IS NULL OR valid_to > @valid_at)
AND sys_from <= @sys_at
AND (sys_to IS NULL OR sys_to > @sys_at)
ORDER BY created_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("valid_at", validAt);
command.Parameters.AddWithValue("sys_at", sysAt);
return await ReadUnknownsAsync(command, cancellationToken);
}
public async Task<Unknown> ResolveAsync(
string tenantId,
Guid id,
ResolutionType resolutionType,
string? resolutionRef,
string? resolutionNotes,
string resolvedBy,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
const string sql = """
UPDATE unknowns.unknown
SET resolved_at = @resolved_at,
resolution_type = @resolution_type::unknowns.resolution_type,
resolution_ref = @resolution_ref,
resolution_notes = @resolution_notes,
valid_to = @valid_to,
updated_at = @updated_at
WHERE tenant_id = @tenant_id
AND id = @id
AND sys_to IS NULL
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("resolved_at", now);
command.Parameters.AddWithValue("resolution_type", MapResolutionType(resolutionType));
command.Parameters.AddWithValue("resolution_ref", (object?)resolutionRef ?? DBNull.Value);
command.Parameters.AddWithValue("resolution_notes", (object?)resolutionNotes ?? DBNull.Value);
command.Parameters.AddWithValue("valid_to", now);
command.Parameters.AddWithValue("updated_at", now);
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
}
_logger.LogInformation("Resolved unknown {Id} with type {ResolutionType}", id, resolutionType);
var resolved = await GetByIdAsync(tenantId, id, cancellationToken);
return resolved ?? throw new InvalidOperationException($"Failed to retrieve resolved unknown {id}.");
}
public async Task SupersedeAsync(
string tenantId,
Guid id,
string supersededBy,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
const string sql = """
UPDATE unknowns.unknown
SET sys_to = @sys_to,
updated_at = @updated_at
WHERE tenant_id = @tenant_id
AND id = @id
AND sys_to IS NULL
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("sys_to", now);
command.Parameters.AddWithValue("updated_at", now);
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException($"Unknown {id} not found or already superseded.");
}
_logger.LogDebug("Superseded unknown {Id} by {SupersededBy}", id, supersededBy);
}
public async Task<IReadOnlyDictionary<UnknownKind, long>> CountByKindAsync(
string tenantId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT kind::text, count(*)
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND valid_to IS NULL
AND sys_to IS NULL
GROUP BY kind
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
var result = new Dictionary<UnknownKind, long>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var kindStr = reader.GetFieldValue<string>(0);
var count = reader.GetInt64(1);
result[ParseUnknownKind(kindStr)] = count;
}
return result;
}
public async Task<IReadOnlyDictionary<UnknownSeverity, long>> CountBySeverityAsync(
string tenantId,
CancellationToken cancellationToken)
{
const string sql = """
SELECT severity::text, count(*)
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND valid_to IS NULL
AND sys_to IS NULL
AND severity IS NOT NULL
GROUP BY severity
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
var result = new Dictionary<UnknownSeverity, long>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var severityStr = reader.GetFieldValue<string>(0);
var count = reader.GetInt64(1);
result[ParseSeverity(severityStr)] = count;
}
return result;
}
public async Task<long> CountOpenAsync(string tenantId, CancellationToken cancellationToken)
{
const string sql = """
SELECT count(*)
FROM unknowns.unknown
WHERE tenant_id = @tenant_id
AND valid_to IS NULL
AND sys_to IS NULL
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is long count ? count : 0;
}
private static async Task SetTenantContextAsync(
NpgsqlConnection connection,
string tenantId,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"SELECT set_config('app.tenant_id', @tenant_id, false)",
connection);
command.Parameters.AddWithValue("tenant_id", tenantId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<IReadOnlyList<Unknown>> ReadUnknownsAsync(
NpgsqlCommand command,
CancellationToken cancellationToken)
{
var results = new List<Unknown>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapUnknown(reader));
}
return results;
}
private static Unknown MapUnknown(NpgsqlDataReader reader)
{
var contextJson = reader.IsDBNull(7) ? null : reader.GetFieldValue<string>(7);
return new Unknown
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
SubjectHash = reader.GetString(2),
SubjectType = ParseSubjectType(reader.GetFieldValue<string>(3)),
SubjectRef = reader.GetString(4),
Kind = ParseUnknownKind(reader.GetFieldValue<string>(5)),
Severity = reader.IsDBNull(6) ? null : ParseSeverity(reader.GetFieldValue<string>(6)),
Context = contextJson is not null ? JsonDocument.Parse(contextJson) : null,
SourceScanId = reader.IsDBNull(8) ? null : reader.GetGuid(8),
SourceGraphId = reader.IsDBNull(9) ? null : reader.GetGuid(9),
SourceSbomDigest = reader.IsDBNull(10) ? null : reader.GetString(10),
ValidFrom = reader.GetFieldValue<DateTimeOffset>(11),
ValidTo = reader.IsDBNull(12) ? null : reader.GetFieldValue<DateTimeOffset>(12),
SysFrom = reader.GetFieldValue<DateTimeOffset>(13),
SysTo = reader.IsDBNull(14) ? null : reader.GetFieldValue<DateTimeOffset>(14),
ResolvedAt = reader.IsDBNull(15) ? null : reader.GetFieldValue<DateTimeOffset>(15),
ResolutionType = reader.IsDBNull(16) ? null : ParseResolutionType(reader.GetFieldValue<string>(16)),
ResolutionRef = reader.IsDBNull(17) ? null : reader.GetString(17),
ResolutionNotes = reader.IsDBNull(18) ? null : reader.GetString(18),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
CreatedBy = reader.GetString(20),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(21)
};
}
private static string ComputeSubjectHash(string subjectRef)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(subjectRef));
return Convert.ToHexStringLower(bytes);
}
// Enum mapping helpers
private static string MapSubjectType(UnknownSubjectType type) => type switch
{
UnknownSubjectType.Package => "package",
UnknownSubjectType.Ecosystem => "ecosystem",
UnknownSubjectType.Version => "version",
UnknownSubjectType.SbomEdge => "sbom_edge",
UnknownSubjectType.File => "file",
UnknownSubjectType.Runtime => "runtime",
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
private static UnknownSubjectType ParseSubjectType(string value) => value switch
{
"package" => UnknownSubjectType.Package,
"ecosystem" => UnknownSubjectType.Ecosystem,
"version" => UnknownSubjectType.Version,
"sbom_edge" => UnknownSubjectType.SbomEdge,
"file" => UnknownSubjectType.File,
"runtime" => UnknownSubjectType.Runtime,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapUnknownKind(UnknownKind kind) => kind switch
{
UnknownKind.MissingSbom => "missing_sbom",
UnknownKind.AmbiguousPackage => "ambiguous_package",
UnknownKind.MissingFeed => "missing_feed",
UnknownKind.UnresolvedEdge => "unresolved_edge",
UnknownKind.NoVersionInfo => "no_version_info",
UnknownKind.UnknownEcosystem => "unknown_ecosystem",
UnknownKind.PartialMatch => "partial_match",
UnknownKind.VersionRangeUnbounded => "version_range_unbounded",
UnknownKind.UnsupportedFormat => "unsupported_format",
UnknownKind.TransitiveGap => "transitive_gap",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
private static UnknownKind ParseUnknownKind(string value) => value switch
{
"missing_sbom" => UnknownKind.MissingSbom,
"ambiguous_package" => UnknownKind.AmbiguousPackage,
"missing_feed" => UnknownKind.MissingFeed,
"unresolved_edge" => UnknownKind.UnresolvedEdge,
"no_version_info" => UnknownKind.NoVersionInfo,
"unknown_ecosystem" => UnknownKind.UnknownEcosystem,
"partial_match" => UnknownKind.PartialMatch,
"version_range_unbounded" => UnknownKind.VersionRangeUnbounded,
"unsupported_format" => UnknownKind.UnsupportedFormat,
"transitive_gap" => UnknownKind.TransitiveGap,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapSeverity(UnknownSeverity severity) => severity switch
{
UnknownSeverity.Critical => "critical",
UnknownSeverity.High => "high",
UnknownSeverity.Medium => "medium",
UnknownSeverity.Low => "low",
UnknownSeverity.Info => "info",
_ => throw new ArgumentOutOfRangeException(nameof(severity))
};
private static UnknownSeverity ParseSeverity(string value) => value switch
{
"critical" => UnknownSeverity.Critical,
"high" => UnknownSeverity.High,
"medium" => UnknownSeverity.Medium,
"low" => UnknownSeverity.Low,
"info" => UnknownSeverity.Info,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
private static string MapResolutionType(ResolutionType type) => type switch
{
ResolutionType.FeedUpdated => "feed_updated",
ResolutionType.SbomProvided => "sbom_provided",
ResolutionType.ManualMapping => "manual_mapping",
ResolutionType.Superseded => "superseded",
ResolutionType.FalsePositive => "false_positive",
ResolutionType.WontFix => "wont_fix",
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
private static ResolutionType ParseResolutionType(string value) => value switch
{
"feed_updated" => ResolutionType.FeedUpdated,
"sbom_provided" => ResolutionType.SbomProvided,
"manual_mapping" => ResolutionType.ManualMapping,
"superseded" => ResolutionType.Superseded,
"false_positive" => ResolutionType.FalsePositive,
"wont_fix" => ResolutionType.WontFix,
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
}

View File

@@ -0,0 +1,27 @@
<?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-preview.1.25080.5" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,327 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Storage.Postgres.Repositories;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Unknowns.Storage.Postgres.Tests;
public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16")
.Build();
private NpgsqlDataSource _dataSource = null!;
private PostgresUnknownRepository _repository = null!;
private const string TestTenantId = "test-tenant";
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var connectionString = _postgres.GetConnectionString();
_dataSource = NpgsqlDataSource.Create(connectionString);
// Run schema migrations
await RunMigrationsAsync();
_repository = new PostgresUnknownRepository(
_dataSource,
NullLogger<PostgresUnknownRepository>.Instance);
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
await _postgres.DisposeAsync();
}
private async Task RunMigrationsAsync()
{
await using var connection = await _dataSource.OpenConnectionAsync();
// Create schema and types
const string schema = """
CREATE SCHEMA IF NOT EXISTS unknowns;
CREATE SCHEMA IF NOT EXISTS unknowns_app;
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';
END IF;
RETURN v_tenant;
END;
$$;
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 $$;
CREATE TABLE IF NOT EXISTS unknowns.unknown (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
subject_hash CHAR(64) NOT NULL,
subject_type unknowns.subject_type NOT NULL,
subject_ref TEXT NOT NULL,
kind unknowns.unknown_kind NOT NULL,
severity unknowns.unknown_severity,
context JSONB NOT NULL DEFAULT '{}',
source_scan_id UUID,
source_graph_id UUID,
source_sbom_digest TEXT,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_to TIMESTAMPTZ,
sys_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sys_to TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
resolution_type unknowns.resolution_type,
resolution_ref TEXT,
resolution_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL DEFAULT 'system',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
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;
""";
await using var command = new NpgsqlCommand(schema, connection);
await command.ExecuteNonQueryAsync();
}
[Fact]
public async Task CreateAsync_ShouldCreateUnknown()
{
// Arrange
var subjectRef = "pkg:npm/lodash@4.17.21";
var kind = UnknownKind.MissingFeed;
var severity = UnknownSeverity.Medium;
// Act
var result = await _repository.CreateAsync(
TestTenantId,
UnknownSubjectType.Package,
subjectRef,
kind,
severity,
"""{"ecosystem": "npm"}""",
null,
null,
null,
"test-user",
CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.TenantId.Should().Be(TestTenantId);
result.SubjectRef.Should().Be(subjectRef);
result.Kind.Should().Be(kind);
result.Severity.Should().Be(severity);
result.IsOpen.Should().BeTrue();
result.IsResolved.Should().BeFalse();
result.IsCurrent.Should().BeTrue();
}
[Fact]
public async Task GetByIdAsync_ShouldReturnUnknown_WhenExists()
{
// Arrange
var created = await _repository.CreateAsync(
TestTenantId,
UnknownSubjectType.Package,
"pkg:npm/axios@0.21.0",
UnknownKind.AmbiguousPackage,
UnknownSeverity.Low,
null,
null,
null,
null,
"test-user",
CancellationToken.None);
// Act
var result = await _repository.GetByIdAsync(TestTenantId, created.Id, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(created.Id);
result.SubjectRef.Should().Be("pkg:npm/axios@0.21.0");
}
[Fact]
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByIdAsync(TestTenantId, Guid.NewGuid(), CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetOpenUnknownsAsync_ShouldReturnOnlyOpenUnknowns()
{
// Arrange
await _repository.CreateAsync(
TestTenantId,
UnknownSubjectType.Package,
"pkg:npm/open1@1.0.0",
UnknownKind.MissingFeed,
UnknownSeverity.High,
null, null, null, null, "test-user", CancellationToken.None);
var toResolve = await _repository.CreateAsync(
TestTenantId,
UnknownSubjectType.Package,
"pkg:npm/resolved@1.0.0",
UnknownKind.MissingFeed,
UnknownSeverity.Medium,
null, null, null, null, "test-user", CancellationToken.None);
await _repository.ResolveAsync(
TestTenantId,
toResolve.Id,
ResolutionType.FeedUpdated,
null,
"Feed now covers this package",
"test-user",
CancellationToken.None);
// Act
var results = await _repository.GetOpenUnknownsAsync(TestTenantId, cancellationToken: CancellationToken.None);
// Assert
results.Should().HaveCount(1);
results[0].SubjectRef.Should().Be("pkg:npm/open1@1.0.0");
}
[Fact]
public async Task ResolveAsync_ShouldMarkAsResolved()
{
// Arrange
var created = await _repository.CreateAsync(
TestTenantId,
UnknownSubjectType.Package,
"pkg:npm/resolvable@2.0.0",
UnknownKind.MissingSbom,
UnknownSeverity.High,
null, null, null, null, "test-user", CancellationToken.None);
// Act
var resolved = await _repository.ResolveAsync(
TestTenantId,
created.Id,
ResolutionType.SbomProvided,
"sbom://digest/abc123",
"SBOM uploaded by vendor",
"test-user",
CancellationToken.None);
// Assert
resolved.IsResolved.Should().BeTrue();
resolved.ResolutionType.Should().Be(ResolutionType.SbomProvided);
resolved.ResolutionRef.Should().Be("sbom://digest/abc123");
resolved.ValidTo.Should().NotBeNull();
}
[Fact]
public async Task CountByKindAsync_ShouldReturnCorrectCounts()
{
// Arrange
var tenant = "count-test-tenant";
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
"pkg:1", UnknownKind.MissingFeed, null, null, null, null, null, "user", CancellationToken.None);
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
"pkg:2", UnknownKind.MissingFeed, null, null, null, null, null, "user", CancellationToken.None);
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
"pkg:3", UnknownKind.AmbiguousPackage, null, null, null, null, null, "user", CancellationToken.None);
// Act
var counts = await _repository.CountByKindAsync(tenant, CancellationToken.None);
// Assert
counts.Should().ContainKey(UnknownKind.MissingFeed);
counts[UnknownKind.MissingFeed].Should().Be(2);
counts.Should().ContainKey(UnknownKind.AmbiguousPackage);
counts[UnknownKind.AmbiguousPackage].Should().Be(1);
}
[Fact]
public async Task AsOfAsync_ShouldReturnHistoricalState()
{
// Arrange
var tenant = "temporal-test-tenant";
var beforeCreate = DateTimeOffset.UtcNow.AddSeconds(-1);
var created = await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
"pkg:temporal@1.0.0", UnknownKind.NoVersionInfo, null, null, null, null, null, "user", CancellationToken.None);
var afterCreate = DateTimeOffset.UtcNow.AddSeconds(1);
// Act
var beforeResults = await _repository.AsOfAsync(tenant, beforeCreate, cancellationToken: CancellationToken.None);
var afterResults = await _repository.AsOfAsync(tenant, afterCreate, cancellationToken: CancellationToken.None);
// Assert
beforeResults.Should().BeEmpty();
afterResults.Should().HaveCount(1);
afterResults[0].Id.Should().Be(created.Id);
}
[Fact]
public async Task SupersedeAsync_ShouldSetSysTo()
{
// Arrange
var tenant = "supersede-test-tenant";
var created = await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
"pkg:supersede@1.0.0", UnknownKind.PartialMatch, null, null, null, null, null, "user", CancellationToken.None);
// Act
await _repository.SupersedeAsync(tenant, created.Id, "new-unknown-id", CancellationToken.None);
// Assert
var result = await _repository.GetByIdAsync(tenant, created.Id, CancellationToken.None);
// After supersede, sys_to is set, so GetById (which filters sys_to IS NULL) returns null
result.Should().BeNull();
}
}

View File

@@ -0,0 +1,35 @@
<?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" />
</ItemGroup>
</Project>