Files
git.stella-ops.org/src/Unknowns/StellaOps.Unknowns.WebService/Endpoints/GreyQueueEndpoints.cs
2026-02-23 23:44:50 +02:00

829 lines
30 KiB
C#

// -----------------------------------------------------------------------------
// GreyQueueEndpoints.cs
// Description: Minimal API endpoints for Grey Queue management.
// Implements signed, replayable evidence pipeline for ambiguous unknowns.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.Unknowns.WebService.Endpoints;
/// <summary>
/// Minimal API endpoints for Grey Queue service.
/// </summary>
public static class GreyQueueEndpoints
{
/// <summary>
/// Maps all Grey Queue endpoints.
/// </summary>
public static IEndpointRouteBuilder MapGreyQueueEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/grey-queue")
.WithTags("GreyQueue")
.RequireAuthorization(UnknownsPolicies.Read)
.RequireTenant();
// List and query
group.MapGet("/", ListEntries)
.WithName("ListGreyQueueEntries")
.WithSummary("List grey queue entries with pagination")
.WithDescription("Returns paginated list of grey queue entries. Supports filtering by status and reason.");
group.MapGet("/{id:guid}", GetEntryById)
.WithName("GetGreyQueueEntry")
.WithSummary("Get grey queue entry by ID")
.WithDescription("Returns a single grey queue entry with full evidence bundle.");
group.MapGet("/by-unknown/{unknownId:guid}", GetByUnknownId)
.WithName("GetGreyQueueByUnknownId")
.WithSummary("Get grey queue entry by unknown ID")
.WithDescription("Returns the grey queue entry for a specific unknown.");
group.MapGet("/ready", GetReadyForProcessing)
.WithName("GetReadyForProcessing")
.WithSummary("Get entries ready for processing")
.WithDescription("Returns entries that are ready to be processed (pending, not exhausted, past next processing time).");
// Triggers
group.MapGet("/triggers/feed/{feedId}", GetByFeedTrigger)
.WithName("GetByFeedTrigger")
.WithSummary("Get entries triggered by feed update")
.WithDescription("Returns entries that should be reprocessed due to a feed update.");
group.MapGet("/triggers/tool/{toolId}", GetByToolTrigger)
.WithName("GetByToolTrigger")
.WithSummary("Get entries triggered by tool update")
.WithDescription("Returns entries that should be reprocessed due to a tool update.");
group.MapGet("/triggers/cve/{cveId}", GetByCveTrigger)
.WithName("GetByCveTrigger")
.WithSummary("Get entries triggered by CVE update")
.WithDescription("Returns entries that should be reprocessed due to a CVE update.");
// Actions (require write scope)
group.MapPost("/", EnqueueEntry)
.WithName("EnqueueGreyQueueEntry")
.WithSummary("Enqueue a new grey queue entry")
.WithDescription("Creates a new grey queue entry with evidence bundle and trigger conditions.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/process", StartProcessing)
.WithName("StartGreyQueueProcessing")
.WithSummary("Mark entry as processing")
.WithDescription("Marks an entry as currently being processed.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/result", RecordResult)
.WithName("RecordGreyQueueResult")
.WithSummary("Record processing result")
.WithDescription("Records the result of a processing attempt.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/resolve", ResolveEntry)
.WithName("ResolveGreyQueueEntry")
.WithSummary("Resolve a grey queue entry")
.WithDescription("Marks an entry as resolved with resolution type and reference.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/dismiss", DismissEntry)
.WithName("DismissGreyQueueEntry")
.WithSummary("Dismiss a grey queue entry")
.WithDescription("Manually dismisses an entry from the queue.")
.RequireAuthorization(UnknownsPolicies.Write);
// Maintenance (require write scope)
group.MapPost("/expire", ExpireOldEntries)
.WithName("ExpireGreyQueueEntries")
.WithSummary("Expire old entries")
.WithDescription("Expires entries that have exceeded their TTL.")
.RequireAuthorization(UnknownsPolicies.Write);
// Statistics
group.MapGet("/summary", GetSummary)
.WithName("GetGreyQueueSummary")
.WithSummary("Get grey queue summary statistics")
.WithDescription("Returns summary counts by status, reason, and performance metrics.");
// Sprint: SPRINT_20260118_018 (UQ-005) - New state transitions (require write scope)
group.MapPost("/{id:guid}/assign", AssignForReview)
.WithName("AssignGreyQueueEntry")
.WithSummary("Assign entry for review")
.WithDescription("Assigns an entry to a reviewer, transitioning to UnderReview state.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/escalate", EscalateEntry)
.WithName("EscalateGreyQueueEntry")
.WithSummary("Escalate entry to security team")
.WithDescription("Escalates an entry to the security team, transitioning to Escalated state.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/reject", RejectEntry)
.WithName("RejectGreyQueueEntry")
.WithSummary("Reject a grey queue entry")
.WithDescription("Marks an entry as rejected (invalid or not actionable).")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapPost("/{id:guid}/reopen", ReopenEntry)
.WithName("ReopenGreyQueueEntry")
.WithSummary("Reopen a closed entry")
.WithDescription("Reopens a rejected, failed, or dismissed entry back to pending.")
.RequireAuthorization(UnknownsPolicies.Write);
group.MapGet("/{id:guid}/transitions", GetValidTransitions)
.WithName("GetValidTransitions")
.WithSummary("Get valid state transitions")
.WithDescription("Returns the valid next states for an entry based on current state.");
return routes;
}
// Sprint: SPRINT_20260118_018 (UQ-005) - Assign for review
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> AssignForReview(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] AssignForReviewRequest request,
IGreyQueueRepository repository = null!,
INotificationPublisher? notificationPublisher = null,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
try
{
GreyQueueStateMachine.ValidateUnderReviewTransition(entry.Status, request.Assignee);
}
catch (InvalidOperationException ex)
{
return TypedResults.BadRequest(ex.Message);
}
var updated = await repository.TransitionToUnderReviewAsync(
tenantId, id, request.Assignee, ct);
return TypedResults.Ok(MapToDto(updated));
}
// Sprint: SPRINT_20260118_018 (UQ-005) - Escalate to security team
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> EscalateEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] EscalateRequest request,
IGreyQueueRepository repository = null!,
INotificationPublisher? notificationPublisher = null,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
try
{
GreyQueueStateMachine.ValidateTransition(entry.Status, GreyQueueStatus.Escalated);
}
catch (InvalidOperationException ex)
{
return TypedResults.BadRequest(ex.Message);
}
var updated = await repository.TransitionToEscalatedAsync(
tenantId, id, request.Reason, ct);
// Notify security team
if (notificationPublisher != null)
{
await notificationPublisher.PublishAsync(new EscalationNotification
{
EntryId = id,
BomRef = entry.BomRef ?? string.Empty,
Reason = request.Reason,
EscalatedAt = DateTimeOffset.UtcNow
}, ct);
}
return TypedResults.Ok(MapToDto(updated));
}
// Sprint: SPRINT_20260118_018 (UQ-005) - Reject entry
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> RejectEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] RejectRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
try
{
GreyQueueStateMachine.ValidateTransition(entry.Status, GreyQueueStatus.Rejected);
}
catch (InvalidOperationException ex)
{
return TypedResults.BadRequest(ex.Message);
}
var updated = await repository.TransitionToRejectedAsync(
tenantId, id, request.Reason, request.RejectedBy, ct);
return TypedResults.Ok(MapToDto(updated));
}
// Sprint: SPRINT_20260118_018 (UQ-005) - Reopen entry
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> ReopenEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] ReopenRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
try
{
GreyQueueStateMachine.ValidateTransition(entry.Status, GreyQueueStatus.Pending);
}
catch (InvalidOperationException ex)
{
return TypedResults.BadRequest(ex.Message);
}
var updated = await repository.ReopenAsync(tenantId, id, request.Reason, ct);
return TypedResults.Ok(MapToDto(updated));
}
// Sprint: SPRINT_20260118_018 (UQ-005) - Get valid transitions
private static async Task<Results<Ok<ValidTransitionsResponse>, NotFound>> GetValidTransitions(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
var validStates = GreyQueueStateMachine.GetValidNextStates(entry.Status);
var response = new ValidTransitionsResponse
{
CurrentState = entry.Status.ToString(),
ValidNextStates = validStates.Select(s => s.ToString()).ToList()
};
return TypedResults.Ok(response);
}
// List entries with pagination
private static async Task<Ok<GreyQueueListResponse>> ListEntries(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
[FromQuery] GreyQueueStatus? status = null,
[FromQuery] GreyQueueReason? reason = null,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
IReadOnlyList<GreyQueueEntry> entries;
if (status.HasValue)
{
entries = await repository.GetByStatusAsync(tenantId, status.Value, take, skip, ct);
}
else if (reason.HasValue)
{
entries = await repository.GetByReasonAsync(tenantId, reason.Value, take, ct);
}
else
{
entries = await repository.GetByStatusAsync(tenantId, GreyQueueStatus.Pending, take, skip, ct);
}
var total = await repository.CountPendingAsync(tenantId, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = total,
Skip = skip,
Take = take
};
return TypedResults.Ok(response);
}
// Get entry by ID
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetEntryById(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDto(entry));
}
// Get by unknown ID
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetByUnknownId(
Guid unknownId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByUnknownIdAsync(tenantId, unknownId, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDto(entry));
}
// Get ready for processing
private static async Task<Ok<GreyQueueListResponse>> GetReadyForProcessing(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetReadyForProcessingAsync(tenantId, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get by feed trigger
private static async Task<Ok<GreyQueueListResponse>> GetByFeedTrigger(
string feedId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] string? version = null,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetByFeedTriggerAsync(tenantId, feedId, version, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get by tool trigger
private static async Task<Ok<GreyQueueListResponse>> GetByToolTrigger(
string toolId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] string? version = null,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetByToolTriggerAsync(tenantId, toolId, version, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get by CVE trigger
private static async Task<Ok<GreyQueueListResponse>> GetByCveTrigger(
string cveId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetByCveTriggerAsync(tenantId, cveId, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Enqueue new entry
private static async Task<Created<GreyQueueEntryDto>> EnqueueEntry(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] EnqueueGreyQueueRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var evidence = request.Evidence is not null ? new GreyQueueEvidenceBundle
{
SbomSliceJson = request.Evidence.SbomSliceJson,
AdvisorySnippetJson = request.Evidence.AdvisorySnippetJson,
VexEvidenceJson = request.Evidence.VexEvidenceJson,
DiffTracesJson = request.Evidence.DiffTracesJson,
ReachabilityEvidenceJson = request.Evidence.ReachabilityEvidenceJson
} : null;
var triggers = request.Triggers is not null ? new GreyQueueTriggers
{
Feeds = request.Triggers.Feeds?.Select(f => new FeedTrigger(f.FeedId, f.MinVersion)).ToList() ?? [],
Tools = request.Triggers.Tools?.Select(t => new ToolTrigger(t.ToolId, t.MinVersion)).ToList() ?? [],
CveIds = request.Triggers.CveIds ?? [],
PurlPatterns = request.Triggers.PurlPatterns ?? []
} : null;
var entry = await repository.EnqueueAsync(
tenantId,
request.UnknownId,
request.Reason,
request.ReasonDetail,
evidence,
triggers,
request.Priority,
request.CreatedBy,
request.CorrelationId,
ct);
return TypedResults.Created($"/api/grey-queue/{entry.Id}", MapToDto(entry));
}
// Start processing
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> StartProcessing(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.StartProcessingAsync(tenantId, id, ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Record result
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> RecordResult(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] RecordResultRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.RecordProcessingResultAsync(
tenantId,
id,
request.Success,
request.Result,
request.NextProcessingAt,
ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Resolve entry
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> ResolveEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] ResolveEntryRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.ResolveAsync(
tenantId,
id,
request.Resolution,
request.ResolutionRef,
ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Dismiss entry
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> DismissEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] DismissEntryRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.DismissAsync(
tenantId,
id,
request.DismissedBy,
request.Reason,
ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Expire old entries
private static async Task<Ok<ExpireResultResponse>> ExpireOldEntries(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var count = await repository.ExpireOldEntriesAsync(tenantId, ct);
return TypedResults.Ok(new ExpireResultResponse { ExpiredCount = count });
}
// Get summary
private static async Task<Ok<GreyQueueSummaryDto>> GetSummary(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var summary = await repository.GetSummaryAsync(tenantId, ct);
var response = new GreyQueueSummaryDto
{
Total = summary.Total,
Pending = summary.Pending,
Processing = summary.Processing,
Retrying = summary.Retrying,
Resolved = summary.Resolved,
Failed = summary.Failed,
Expired = summary.Expired,
Dismissed = summary.Dismissed,
ByReason = summary.ByReason,
AvgAttemptsToResolve = summary.AvgAttemptsToResolve,
AvgHoursToResolve = summary.AvgHoursToResolve,
OldestPendingHours = summary.OldestPendingHours
};
return TypedResults.Ok(response);
}
// Mapping helpers
private static GreyQueueEntryDto MapToDto(GreyQueueEntry e) => new()
{
Id = e.Id,
TenantId = e.TenantId,
UnknownId = e.UnknownId,
Fingerprint = e.Fingerprint,
Status = e.Status.ToString(),
Priority = e.Priority,
Reason = e.Reason.ToString(),
ReasonDetail = e.ReasonDetail,
HasSbomSlice = e.SbomSlice is not null,
HasAdvisorySnippet = e.AdvisorySnippet is not null,
HasVexEvidence = e.VexEvidence is not null,
HasDiffTraces = e.DiffTraces is not null,
HasReachabilityEvidence = e.ReachabilityEvidence is not null,
ProcessingAttempts = e.ProcessingAttempts,
MaxAttempts = e.MaxAttempts,
LastProcessedAt = e.LastProcessedAt,
LastProcessingResult = e.LastProcessingResult,
NextProcessingAt = e.NextProcessingAt,
TriggerCveIds = e.TriggerOnCveUpdate.ToList(),
TriggerPurlPatterns = e.TriggerOnPurlMatch.ToList(),
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt,
ExpiresAt = e.ExpiresAt,
ResolvedAt = e.ResolvedAt,
Resolution = e.Resolution?.ToString(),
ResolutionRef = e.ResolutionRef,
IsPending = e.IsPending,
IsExhausted = e.IsExhausted,
IsReadyForProcessing = e.IsReadyForProcessing
};
}
// DTOs
public sealed record GreyQueueListResponse
{
public required IReadOnlyList<GreyQueueEntryDto> Items { get; init; }
public required long Total { get; init; }
public required int Skip { get; init; }
public required int Take { get; init; }
}
public sealed record GreyQueueEntryDto
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid UnknownId { get; init; }
public required string Fingerprint { get; init; }
public required string Status { get; init; }
public required int Priority { get; init; }
public required string Reason { get; init; }
public string? ReasonDetail { get; init; }
public required bool HasSbomSlice { get; init; }
public required bool HasAdvisorySnippet { get; init; }
public required bool HasVexEvidence { get; init; }
public required bool HasDiffTraces { get; init; }
public required bool HasReachabilityEvidence { get; init; }
public required int ProcessingAttempts { get; init; }
public required int MaxAttempts { get; init; }
public DateTimeOffset? LastProcessedAt { get; init; }
public string? LastProcessingResult { get; init; }
public DateTimeOffset? NextProcessingAt { get; init; }
public required IReadOnlyList<string> TriggerCveIds { get; init; }
public required IReadOnlyList<string> TriggerPurlPatterns { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? Resolution { get; init; }
public string? ResolutionRef { get; init; }
public required bool IsPending { get; init; }
public required bool IsExhausted { get; init; }
public required bool IsReadyForProcessing { get; init; }
}
public sealed record GreyQueueSummaryDto
{
public required long Total { get; init; }
public required long Pending { get; init; }
public required long Processing { get; init; }
public required long Retrying { get; init; }
public required long Resolved { get; init; }
public required long Failed { get; init; }
public required long Expired { get; init; }
public required long Dismissed { get; init; }
public required IReadOnlyDictionary<string, long> ByReason { get; init; }
public double AvgAttemptsToResolve { get; init; }
public double AvgHoursToResolve { get; init; }
public double OldestPendingHours { get; init; }
}
public sealed record EnqueueGreyQueueRequest
{
public required Guid UnknownId { get; init; }
public required GreyQueueReason Reason { get; init; }
public string? ReasonDetail { get; init; }
public EvidenceBundleDto? Evidence { get; init; }
public TriggersDto? Triggers { get; init; }
public int Priority { get; init; } = 100;
public required string CreatedBy { get; init; }
public string? CorrelationId { get; init; }
}
public sealed record EvidenceBundleDto
{
public string? SbomSliceJson { get; init; }
public string? AdvisorySnippetJson { get; init; }
public string? VexEvidenceJson { get; init; }
public string? DiffTracesJson { get; init; }
public string? ReachabilityEvidenceJson { get; init; }
}
public sealed record TriggersDto
{
public IReadOnlyList<FeedTriggerDto>? Feeds { get; init; }
public IReadOnlyList<ToolTriggerDto>? Tools { get; init; }
public IReadOnlyList<string>? CveIds { get; init; }
public IReadOnlyList<string>? PurlPatterns { get; init; }
}
public sealed record FeedTriggerDto
{
public required string FeedId { get; init; }
public string? MinVersion { get; init; }
}
public sealed record ToolTriggerDto
{
public required string ToolId { get; init; }
public string? MinVersion { get; init; }
}
public sealed record RecordResultRequest
{
public required bool Success { get; init; }
public required string Result { get; init; }
public DateTimeOffset? NextProcessingAt { get; init; }
}
public sealed record ResolveEntryRequest
{
public required GreyQueueResolution Resolution { get; init; }
public string? ResolutionRef { get; init; }
}
public sealed record DismissEntryRequest
{
public required string DismissedBy { get; init; }
public string? Reason { get; init; }
}
public sealed record ExpireResultResponse
{
public required int ExpiredCount { get; init; }
}
// Sprint: SPRINT_20260118_018 (UQ-005) - New DTOs for state transitions
public sealed record AssignForReviewRequest
{
/// <summary>Required: The assignee for review.</summary>
public required string Assignee { get; init; }
/// <summary>Optional notes for the reviewer.</summary>
public string? Notes { get; init; }
}
public sealed record EscalateRequest
{
/// <summary>Reason for escalation.</summary>
public required string Reason { get; init; }
}
public sealed record RejectRequest
{
/// <summary>Reason for rejection.</summary>
public required string Reason { get; init; }
/// <summary>Who rejected the entry.</summary>
public required string RejectedBy { get; init; }
}
public sealed record ReopenRequest
{
/// <summary>Reason for reopening.</summary>
public required string Reason { get; init; }
}
public sealed record ValidTransitionsResponse
{
/// <summary>Current state of the entry.</summary>
public required string CurrentState { get; init; }
/// <summary>Valid next states from current state.</summary>
public required List<string> ValidNextStates { get; init; }
}
public sealed record EscalationNotification
{
public required Guid EntryId { get; init; }
public required string BomRef { get; init; }
public required string Reason { get; init; }
public DateTimeOffset EscalatedAt { get; init; }
}
// Interface for notification publishing
public interface INotificationPublisher
{
Task PublishAsync<T>(T notification, CancellationToken ct = default);
}