audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -0,0 +1,27 @@
# AGENTS - Unknowns WebService
## Roles
- Backend engineer: .NET 10 Web API, DI wiring, and repository integration.
- QA / test engineer: endpoint and authorization coverage, determinism checks.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/unknowns/architecture.md
- src/Unknowns/AGENTS.md
- Current sprint file under docs/implplan/SPRINT_*.md
## Working Directory & Boundaries
- Primary scope: src/Unknowns/StellaOps.Unknowns.WebService
- Test scope: src/Unknowns/__Tests/StellaOps.Unknowns.WebService.Tests
- Avoid cross-module edits unless explicitly allowed in the sprint file.
## Determinism and Safety
- Use TimeProvider/IGuidGenerator for time and IDs.
- Validate inputs (tenant id, pagination bounds, confidence thresholds).
- Require authorization and derive tenant identity from claims, not headers.
## Testing
- Cover list/query endpoints, pagination bounds, and tenant isolation.
- Add health check tests for database connectivity and error handling.

View File

@@ -0,0 +1,425 @@
// -----------------------------------------------------------------------------
// UnknownsEndpoints.cs
// Sprint: SPRINT_20260106_001_005_UNKNOWNS_provenance_hints
// Tasks: WS-004, WS-005, WS-006 - Implement API endpoints
// Description: Minimal API endpoints for Unknowns with provenance hints
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
namespace StellaOps.Unknowns.WebService.Endpoints;
/// <summary>
/// Minimal API endpoints for Unknowns service.
/// </summary>
public static class UnknownsEndpoints
{
/// <summary>
/// Maps all Unknowns endpoints.
/// </summary>
public static IEndpointRouteBuilder MapUnknownsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/unknowns")
.WithTags("Unknowns")
.WithOpenApi();
// WS-004: GET /api/unknowns - List with pagination
group.MapGet("/", ListUnknowns)
.WithName("ListUnknowns")
.WithSummary("List unknowns with pagination")
.WithDescription("Returns paginated list of open unknowns. Supports bitemporal query with asOf parameter.");
// WS-005: GET /api/unknowns/{id} - Single with hints
group.MapGet("/{id:guid}", GetUnknownById)
.WithName("GetUnknownById")
.WithSummary("Get unknown by ID")
.WithDescription("Returns a single unknown with full provenance hints.");
// WS-006: GET /api/unknowns/{id}/hints - Hints only
group.MapGet("/{id:guid}/hints", GetUnknownHints)
.WithName("GetUnknownHints")
.WithSummary("Get provenance hints for unknown")
.WithDescription("Returns only the provenance hints for an unknown.");
// Additional endpoints
group.MapGet("/{id:guid}/history", GetUnknownHistory)
.WithName("GetUnknownHistory")
.WithSummary("Get bitemporal history for unknown")
.WithDescription("Returns the bitemporal history of state changes for an unknown.");
group.MapGet("/triage/{band}", GetByTriageBand)
.WithName("GetUnknownsByTriageBand")
.WithSummary("Get unknowns by triage band")
.WithDescription("Returns unknowns filtered by triage band (hot, warm, cold).");
group.MapGet("/hot-queue", GetHotQueue)
.WithName("GetHotQueue")
.WithSummary("Get HOT unknowns for immediate processing")
.WithDescription("Returns HOT unknowns ordered by composite score descending.");
group.MapGet("/high-confidence", GetHighConfidenceHints)
.WithName("GetHighConfidenceHints")
.WithSummary("Get unknowns with high-confidence hints")
.WithDescription("Returns unknowns with provenance hints above confidence threshold.");
group.MapGet("/summary", GetSummary)
.WithName("GetUnknownsSummary")
.WithSummary("Get unknowns summary statistics")
.WithDescription("Returns summary counts by kind, severity, and triage band.");
return routes;
}
// WS-004: List unknowns with pagination
private static async Task<Ok<UnknownsListResponse>> ListUnknowns(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
[FromQuery] DateTimeOffset? asOf = null,
[FromQuery] UnknownKind? kind = null,
[FromQuery] UnknownSeverity? severity = null,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
IReadOnlyList<Unknown> unknowns;
long total;
if (asOf.HasValue)
{
// Bitemporal query
unknowns = await repository.AsOfAsync(tenantId, asOf.Value, null, ct);
total = unknowns.Count;
unknowns = unknowns.Skip(skip).Take(take).ToList();
}
else if (kind.HasValue)
{
unknowns = await repository.GetByKindAsync(tenantId, kind.Value, take, ct);
total = unknowns.Count;
}
else if (severity.HasValue)
{
unknowns = await repository.GetBySeverityAsync(tenantId, severity.Value, take, ct);
total = unknowns.Count;
}
else
{
unknowns = await repository.GetOpenUnknownsAsync(tenantId, take, skip, ct);
total = await repository.CountOpenAsync(tenantId, ct);
}
var response = new UnknownsListResponse
{
Items = unknowns.Select(u => MapToDto(u)).ToList(),
Total = total,
Skip = skip,
Take = take
};
return TypedResults.Ok(response);
}
// WS-005: Get single unknown with hints
private static async Task<Results<Ok<UnknownDto>, NotFound>> GetUnknownById(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
if (unknown is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDto(unknown));
}
// WS-006: Get hints only
private static async Task<Results<Ok<ProvenanceHintsResponse>, NotFound>> GetUnknownHints(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
if (unknown is null)
{
return TypedResults.NotFound();
}
var response = new ProvenanceHintsResponse
{
UnknownId = unknown.Id,
Hints = unknown.ProvenanceHints.Select(h => MapHintToDto(h)).ToList(),
BestHypothesis = unknown.BestHypothesis,
CombinedConfidence = unknown.CombinedConfidence,
PrimarySuggestedAction = unknown.PrimarySuggestedAction
};
return TypedResults.Ok(response);
}
// Get bitemporal history
private static async Task<Results<Ok<UnknownHistoryResponse>, NotFound>> GetUnknownHistory(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] DateTimeOffset? from = null,
[FromQuery] DateTimeOffset? to = null,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
if (unknown is null)
{
return TypedResults.NotFound();
}
// Note: Full history would require additional repository method
// For now, return current state as single history entry
var response = new UnknownHistoryResponse
{
UnknownId = id,
History = [
new UnknownHistoryEntry
{
ValidFrom = unknown.ValidFrom,
ValidTo = unknown.ValidTo,
SysFrom = unknown.SysFrom,
SysTo = unknown.SysTo,
State = MapToDto(unknown)
}
]
};
return TypedResults.Ok(response);
}
// Get by triage band
private static async Task<Ok<UnknownsListResponse>> GetByTriageBand(
TriageBand band,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var unknowns = await repository.GetByTriageBandAsync(tenantId, band, limit, offset, ct);
var response = new UnknownsListResponse
{
Items = unknowns.Select(u => MapToDto(u)).ToList(),
Total = unknowns.Count,
Skip = offset,
Take = limit
};
return TypedResults.Ok(response);
}
// Get HOT queue
private static async Task<Ok<UnknownsListResponse>> GetHotQueue(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int limit = 50,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var unknowns = await repository.GetHotQueueAsync(tenantId, limit, ct);
var response = new UnknownsListResponse
{
Items = unknowns.Select(u => MapToDto(u)).ToList(),
Total = unknowns.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get high-confidence hints
private static async Task<Ok<UnknownsListResponse>> GetHighConfidenceHints(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] double minConfidence = 0.7,
[FromQuery] int limit = 50,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var unknowns = await repository.GetWithHighConfidenceHintsAsync(
tenantId, minConfidence, limit, ct);
var response = new UnknownsListResponse
{
Items = unknowns.Select(u => MapToDto(u)).ToList(),
Total = unknowns.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get summary statistics
private static async Task<Ok<UnknownsSummaryResponse>> GetSummary(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IUnknownRepository repository = null!,
CancellationToken ct = default)
{
var byKind = await repository.CountByKindAsync(tenantId, ct);
var bySeverity = await repository.CountBySeverityAsync(tenantId, ct);
var byBand = await repository.CountByTriageBandAsync(tenantId, ct);
var totalOpen = await repository.CountOpenAsync(tenantId, ct);
var response = new UnknownsSummaryResponse
{
TotalOpen = totalOpen,
ByKind = byKind.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value),
BySeverity = bySeverity.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value),
ByTriageBand = byBand.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value)
};
return TypedResults.Ok(response);
}
// Mapping helpers
private static UnknownDto MapToDto(Unknown u) => new()
{
Id = u.Id,
TenantId = u.TenantId,
SubjectHash = u.SubjectHash,
SubjectType = u.SubjectType.ToString(),
SubjectRef = u.SubjectRef,
Kind = u.Kind.ToString(),
Severity = u.Severity?.ToString(),
SourceScanId = u.SourceScanId,
SourceGraphId = u.SourceGraphId,
SourceSbomDigest = u.SourceSbomDigest,
ValidFrom = u.ValidFrom,
ValidTo = u.ValidTo,
ResolvedAt = u.ResolvedAt,
ResolutionType = u.ResolutionType?.ToString(),
ResolutionRef = u.ResolutionRef,
CompositeScore = u.CompositeScore,
TriageBand = u.TriageBand.ToString(),
IsOpen = u.IsOpen,
IsResolved = u.IsResolved,
ProvenanceHints = u.ProvenanceHints.Select(h => MapHintToDto(h)).ToList(),
BestHypothesis = u.BestHypothesis,
CombinedConfidence = u.CombinedConfidence,
PrimarySuggestedAction = u.PrimarySuggestedAction,
CreatedAt = u.CreatedAt,
UpdatedAt = u.UpdatedAt
};
private static ProvenanceHintDto MapHintToDto(ProvenanceHint h) => new()
{
Id = h.Id,
Type = h.Type.ToString(),
Confidence = h.Confidence,
ConfidenceLevel = h.ConfidenceLevel.ToString(),
Hypothesis = h.Hypothesis,
SuggestedActions = h.SuggestedActions.Select(a => new SuggestedActionDto
{
Action = a.Action,
Priority = a.Priority,
Description = a.Description,
Url = a.Url
}).ToList(),
GeneratedAt = h.GeneratedAt
};
}
// DTOs
public sealed record UnknownsListResponse
{
public required IReadOnlyList<UnknownDto> Items { get; init; }
public required long Total { get; init; }
public required int Skip { get; init; }
public required int Take { get; init; }
}
public sealed record UnknownDto
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string SubjectHash { get; init; }
public required string SubjectType { get; init; }
public required string SubjectRef { get; init; }
public required string Kind { get; init; }
public string? Severity { get; init; }
public Guid? SourceScanId { get; init; }
public Guid? SourceGraphId { get; init; }
public string? SourceSbomDigest { get; init; }
public required DateTimeOffset ValidFrom { get; init; }
public DateTimeOffset? ValidTo { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? ResolutionType { get; init; }
public string? ResolutionRef { get; init; }
public required double CompositeScore { get; init; }
public required string TriageBand { get; init; }
public required bool IsOpen { get; init; }
public required bool IsResolved { get; init; }
public required IReadOnlyList<ProvenanceHintDto> ProvenanceHints { get; init; }
public string? BestHypothesis { get; init; }
public double? CombinedConfidence { get; init; }
public string? PrimarySuggestedAction { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
}
public sealed record ProvenanceHintsResponse
{
public required Guid UnknownId { get; init; }
public required IReadOnlyList<ProvenanceHintDto> Hints { get; init; }
public string? BestHypothesis { get; init; }
public double? CombinedConfidence { get; init; }
public string? PrimarySuggestedAction { get; init; }
}
public sealed record ProvenanceHintDto
{
public required string Id { get; init; }
public required string Type { get; init; }
public required double Confidence { get; init; }
public required string ConfidenceLevel { get; init; }
public required string Hypothesis { get; init; }
public required IReadOnlyList<SuggestedActionDto> SuggestedActions { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
public sealed record SuggestedActionDto
{
public required string Action { get; init; }
public required int Priority { get; init; }
public string? Description { get; init; }
public string? Url { get; init; }
}
public sealed record UnknownHistoryResponse
{
public required Guid UnknownId { get; init; }
public required IReadOnlyList<UnknownHistoryEntry> History { get; init; }
}
public sealed record UnknownHistoryEntry
{
public required DateTimeOffset ValidFrom { get; init; }
public DateTimeOffset? ValidTo { get; init; }
public required DateTimeOffset SysFrom { get; init; }
public DateTimeOffset? SysTo { get; init; }
public required UnknownDto State { get; init; }
}
public sealed record UnknownsSummaryResponse
{
public required long TotalOpen { get; init; }
public required IReadOnlyDictionary<string, long> ByKind { get; init; }
public required IReadOnlyDictionary<string, long> BySeverity { get; init; }
public required IReadOnlyDictionary<string, long> ByTriageBand { get; init; }
}

View File

@@ -0,0 +1,56 @@
// -----------------------------------------------------------------------------
// Program.cs
// Sprint: SPRINT_20260106_001_005_UNKNOWNS_provenance_hints
// Task: WS-002 - Add Program.cs with minimal hosting
// Description: Entry point for Unknowns WebService with OpenAPI, health checks, auth
// -----------------------------------------------------------------------------
using Microsoft.OpenApi.Models;
using StellaOps.Unknowns.WebService;
using StellaOps.Unknowns.WebService.Endpoints;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddUnknownsServices(builder.Configuration);
// OpenAPI / Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "StellaOps Unknowns API",
Version = "v1",
Description = "API for managing unknown components with provenance hints"
});
});
// Health checks
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database");
// Authentication (placeholder - configure based on environment)
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
// Map endpoints
app.MapUnknownsEndpoints();
app.MapHealthChecks("/health");
app.Run();
// Make Program class accessible for integration tests
public partial class Program { }

View File

@@ -0,0 +1,68 @@
// -----------------------------------------------------------------------------
// ServiceCollectionExtensions.cs
// Sprint: SPRINT_20260106_001_005_UNKNOWNS_provenance_hints
// Task: WS-003 - Register IUnknownRepository from PostgreSQL library
// Description: DI registration for Unknowns services
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Persistence;
namespace StellaOps.Unknowns.WebService;
/// <summary>
/// Service collection extensions for Unknowns WebService.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Unknowns services to the service collection.
/// </summary>
public static IServiceCollection AddUnknownsServices(
this IServiceCollection services,
IConfiguration configuration)
{
// Register repository
var connectionString = configuration.GetConnectionString("UnknownsDb")
?? throw new InvalidOperationException("UnknownsDb connection string is required");
services.AddSingleton<IUnknownRepository>(sp =>
new PostgresUnknownRepository(connectionString, sp.GetRequiredService<TimeProvider>()));
// Register TimeProvider
services.AddSingleton(TimeProvider.System);
return services;
}
}
/// <summary>
/// Health check for database connectivity.
/// </summary>
public sealed class DatabaseHealthCheck : IHealthCheck
{
private readonly IUnknownRepository _repository;
public DatabaseHealthCheck(IUnknownRepository repository)
{
_repository = repository;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Simple check - try to list with limit 1
await _repository.ListAsync(skip: 0, take: 1, asOf: null, cancellationToken);
return HealthCheckResult.Healthy("Database connection successful");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database connection failed", ex);
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Unknowns.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\__Libraries\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Unknowns.Persistence\StellaOps.Unknowns.Persistence.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Unknowns.Persistence.EfCore\StellaOps.Unknowns.Persistence.EfCore.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
</Project>