// ----------------------------------------------------------------------------- // 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; /// /// Minimal API endpoints for Unknowns service. /// public static class UnknownsEndpoints { /// /// Maps all Unknowns endpoints. /// 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> 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 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, 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, 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, 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> 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> 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> 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> 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 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 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 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 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 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 ByKind { get; init; } public required IReadOnlyDictionary BySeverity { get; init; } public required IReadOnlyDictionary ByTriageBand { get; init; } }