sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -118,6 +118,10 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<GroundingValidator>();
services.TryAddSingleton<ActionProposalParser>();
// Object link resolvers (SPRINT_20260109_011_002 OMCI-005)
services.TryAddSingleton<ITypedLinkResolver, OpsMemoryLinkResolver>();
services.TryAddSingleton<IObjectLinkResolver, CompositeObjectLinkResolver>();
return services;
}

View File

@@ -0,0 +1,890 @@
// <copyright file="EvidencePackEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
/// <summary>
/// API endpoints for Evidence Packs.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-010
/// </summary>
public static class EvidencePackEndpoints
{
/// <summary>
/// Maps all Evidence Pack endpoints.
/// </summary>
public static void MapEvidencePackEndpoints(this WebApplication app)
{
// POST /v1/evidence-packs - Create Evidence Pack
app.MapPost("/v1/evidence-packs", HandleCreateEvidencePack)
.WithName("evidence-packs.create")
.WithTags("EvidencePacks")
.Produces<EvidencePackResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs/{packId} - Get Evidence Pack
app.MapGet("/v1/evidence-packs/{packId}", HandleGetEvidencePack)
.WithName("evidence-packs.get")
.WithTags("EvidencePacks")
.Produces<EvidencePackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// POST /v1/evidence-packs/{packId}/sign - Sign Evidence Pack
app.MapPost("/v1/evidence-packs/{packId}/sign", HandleSignEvidencePack)
.WithName("evidence-packs.sign")
.WithTags("EvidencePacks")
.Produces<SignedEvidencePackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// POST /v1/evidence-packs/{packId}/verify - Verify Evidence Pack
app.MapPost("/v1/evidence-packs/{packId}/verify", HandleVerifyEvidencePack)
.WithName("evidence-packs.verify")
.WithTags("EvidencePacks")
.Produces<EvidencePackVerificationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs/{packId}/export - Export Evidence Pack
app.MapGet("/v1/evidence-packs/{packId}/export", HandleExportEvidencePack)
.WithName("evidence-packs.export")
.WithTags("EvidencePacks")
.Produces<byte[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/runs/{runId}/evidence-packs - List Evidence Packs for Run
app.MapGet("/v1/runs/{runId}/evidence-packs", HandleListRunEvidencePacks)
.WithName("evidence-packs.list-by-run")
.WithTags("EvidencePacks")
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs - List Evidence Packs
app.MapGet("/v1/evidence-packs", HandleListEvidencePacks)
.WithName("evidence-packs.list")
.WithTags("EvidencePacks")
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
}
private static async Task<IResult> HandleCreateEvidencePack(
CreateEvidencePackRequest request,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
if (request.Claims is null || request.Claims.Count == 0)
{
return Results.BadRequest(new { error = "At least one claim is required" });
}
if (request.Evidence is null || request.Evidence.Count == 0)
{
return Results.BadRequest(new { error = "At least one evidence item is required" });
}
var claims = request.Claims.Select(c => new EvidenceClaim
{
ClaimId = c.ClaimId ?? $"claim-{Guid.NewGuid():N}"[..16],
Text = c.Text,
Type = Enum.TryParse<ClaimType>(c.Type, true, out var ct) ? ct : ClaimType.Custom,
Status = c.Status,
Confidence = c.Confidence,
EvidenceIds = c.EvidenceIds?.ToImmutableArray() ?? [],
Source = c.Source
}).ToArray();
var evidence = request.Evidence.Select(e => new EvidenceItem
{
EvidenceId = e.EvidenceId ?? $"ev-{Guid.NewGuid():N}"[..12],
Type = Enum.TryParse<EvidenceType>(e.Type, true, out var et) ? et : EvidenceType.Custom,
Uri = e.Uri,
Digest = e.Digest ?? "sha256:unknown",
CollectedAt = e.CollectedAt ?? DateTimeOffset.UtcNow,
Snapshot = EvidenceSnapshot.Custom(e.SnapshotType ?? "custom", (e.SnapshotData ?? new Dictionary<string, object>()).ToImmutableDictionary(x => x.Key, x => (object?)x.Value))
}).ToArray();
var subject = new EvidenceSubject
{
Type = Enum.TryParse<EvidenceSubjectType>(request.Subject?.Type, true, out var st)
? st
: EvidenceSubjectType.Custom,
FindingId = request.Subject?.FindingId,
CveId = request.Subject?.CveId,
Component = request.Subject?.Component,
ImageDigest = request.Subject?.ImageDigest
};
var context = new EvidencePackContext
{
TenantId = tenantId,
RunId = request.RunId,
ConversationId = request.ConversationId,
UserId = GetUserId(httpContext),
GeneratedBy = "API"
};
var pack = await evidencePackService.CreateAsync(claims, evidence, subject, context, cancellationToken)
.ConfigureAwait(false);
var response = EvidencePackResponse.FromPack(pack);
return Results.Created($"/v1/evidence-packs/{pack.PackId}", response);
}
private static async Task<IResult> HandleGetEvidencePack(
string packId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
return Results.Ok(EvidencePackResponse.FromPack(pack));
}
private static async Task<IResult> HandleSignEvidencePack(
string packId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
var signedPack = await evidencePackService.SignAsync(pack, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(SignedEvidencePackResponse.FromSignedPack(signedPack));
}
private static async Task<IResult> HandleVerifyEvidencePack(
string packId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
// Get signed version from store
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
var signedPack = store is not null
? await store.GetSignedByIdAsync(tenantId, packId, cancellationToken).ConfigureAwait(false)
: null;
if (signedPack is null)
{
return Results.BadRequest(new { error = "Pack is not signed", packId });
}
var result = await evidencePackService.VerifyAsync(signedPack, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new EvidencePackVerificationResponse
{
PackId = packId,
Valid = result.Valid,
PackDigest = result.PackDigest,
SignatureKeyId = result.SignatureKeyId,
Issues = result.Issues.ToList(),
EvidenceResolutions = result.EvidenceResolutions.Select(r => new EvidenceResolutionApiResponse
{
EvidenceId = r.EvidenceId,
Uri = r.Uri,
Resolved = r.Resolved,
DigestMatches = r.DigestMatches,
Error = r.Error
}).ToList()
});
}
private static async Task<IResult> HandleExportEvidencePack(
string packId,
string? format,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
var exportFormat = format?.ToLowerInvariant() switch
{
"markdown" or "md" => EvidencePackExportFormat.Markdown,
"html" => EvidencePackExportFormat.Html,
"pdf" => EvidencePackExportFormat.Pdf,
"signedjson" => EvidencePackExportFormat.SignedJson,
_ => EvidencePackExportFormat.Json
};
var export = await evidencePackService.ExportAsync(packId, exportFormat, cancellationToken)
.ConfigureAwait(false);
return Results.File(export.Content, export.ContentType, export.FileName);
}
private static async Task<IResult> HandleListRunEvidencePacks(
string runId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
if (store is null)
{
return Results.Ok(new EvidencePackListResponse { Count = 0, Packs = [] });
}
var packs = await store.GetByRunIdAsync(runId, cancellationToken).ConfigureAwait(false);
// Filter by tenant
var filtered = packs.Where(p => p.TenantId == tenantId).ToList();
return Results.Ok(new EvidencePackListResponse
{
Count = filtered.Count,
Packs = filtered.Select(EvidencePackSummary.FromPack).ToList()
});
}
private static async Task<IResult> HandleListEvidencePacks(
string? cveId,
string? runId,
int? limit,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
if (store is null)
{
return Results.Ok(new EvidencePackListResponse { Count = 0, Packs = [] });
}
var query = new EvidencePackQuery
{
CveId = cveId,
RunId = runId,
Limit = Math.Min(limit ?? 50, 100)
};
var packs = await store.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false);
return Results.Ok(new EvidencePackListResponse
{
Count = packs.Count,
Packs = packs.Select(EvidencePackSummary.FromPack).ToList()
});
}
private static string? GetTenantId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenant))
{
return tenant.ToString();
}
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
return tenantClaim;
}
private static string GetUserId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-StellaOps-User", out var user))
{
return user.ToString();
}
return context.User?.FindFirst("sub")?.Value ?? "anonymous";
}
}
#region Request/Response Models
/// <summary>
/// Request to create an Evidence Pack.
/// </summary>
public sealed record CreateEvidencePackRequest
{
/// <summary>Subject of the evidence pack.</summary>
public EvidenceSubjectRequest? Subject { get; init; }
/// <summary>Claims in the pack.</summary>
public IReadOnlyList<EvidenceClaimRequest>? Claims { get; init; }
/// <summary>Evidence items.</summary>
public IReadOnlyList<EvidenceItemRequest>? Evidence { get; init; }
/// <summary>Optional Run ID to link to.</summary>
public string? RunId { get; init; }
/// <summary>Optional conversation ID.</summary>
public string? ConversationId { get; init; }
}
/// <summary>
/// Evidence subject in request.
/// </summary>
public sealed record EvidenceSubjectRequest
{
/// <summary>Subject type.</summary>
public string? Type { get; init; }
/// <summary>Finding ID if applicable.</summary>
public string? FindingId { get; init; }
/// <summary>CVE ID if applicable.</summary>
public string? CveId { get; init; }
/// <summary>Component if applicable.</summary>
public string? Component { get; init; }
/// <summary>Image digest if applicable.</summary>
public string? ImageDigest { get; init; }
}
/// <summary>
/// Evidence claim in request.
/// </summary>
public sealed record EvidenceClaimRequest
{
/// <summary>Optional claim ID (auto-generated if not provided).</summary>
public string? ClaimId { get; init; }
/// <summary>Claim text.</summary>
public required string Text { get; init; }
/// <summary>Claim type.</summary>
public required string Type { get; init; }
/// <summary>Status.</summary>
public required string Status { get; init; }
/// <summary>Confidence score 0-1.</summary>
public double Confidence { get; init; }
/// <summary>Evidence IDs supporting this claim.</summary>
public IReadOnlyList<string>? EvidenceIds { get; init; }
/// <summary>Source of the claim.</summary>
public string? Source { get; init; }
}
/// <summary>
/// Evidence item in request.
/// </summary>
public sealed record EvidenceItemRequest
{
/// <summary>Optional evidence ID (auto-generated if not provided).</summary>
public string? EvidenceId { get; init; }
/// <summary>Evidence type.</summary>
public required string Type { get; init; }
/// <summary>URI to the evidence.</summary>
public required string Uri { get; init; }
/// <summary>Content digest.</summary>
public string? Digest { get; init; }
/// <summary>When the evidence was collected.</summary>
public DateTimeOffset? CollectedAt { get; init; }
/// <summary>Snapshot type.</summary>
public string? SnapshotType { get; init; }
/// <summary>Snapshot data.</summary>
public Dictionary<string, object>? SnapshotData { get; init; }
}
/// <summary>
/// Evidence Pack response.
/// </summary>
public sealed record EvidencePackResponse
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Version.</summary>
public required string Version { get; init; }
/// <summary>Tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Created timestamp.</summary>
public required string CreatedAt { get; init; }
/// <summary>Content digest.</summary>
public required string ContentDigest { get; init; }
/// <summary>Subject.</summary>
public required EvidenceSubjectResponse Subject { get; init; }
/// <summary>Claims.</summary>
public required IReadOnlyList<EvidenceClaimResponse> Claims { get; init; }
/// <summary>Evidence items.</summary>
public required IReadOnlyList<EvidenceItemResponse> Evidence { get; init; }
/// <summary>Context.</summary>
public EvidencePackContextResponse? Context { get; init; }
/// <summary>Related links.</summary>
public EvidencePackLinks? Links { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidencePackResponse FromPack(EvidencePack pack) => new()
{
PackId = pack.PackId,
Version = pack.Version,
TenantId = pack.TenantId,
CreatedAt = pack.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
ContentDigest = pack.ComputeContentDigest(),
Subject = EvidenceSubjectResponse.FromSubject(pack.Subject),
Claims = pack.Claims.Select(EvidenceClaimResponse.FromClaim).ToList(),
Evidence = pack.Evidence.Select(EvidenceItemResponse.FromItem).ToList(),
Context = pack.Context is not null ? EvidencePackContextResponse.FromContext(pack.Context) : null,
Links = new EvidencePackLinks
{
Self = $"/v1/evidence-packs/{pack.PackId}",
Sign = $"/v1/evidence-packs/{pack.PackId}/sign",
Verify = $"/v1/evidence-packs/{pack.PackId}/verify",
Export = $"/v1/evidence-packs/{pack.PackId}/export"
}
};
}
/// <summary>
/// Evidence subject response.
/// </summary>
public sealed record EvidenceSubjectResponse
{
/// <summary>Subject type.</summary>
public required string Type { get; init; }
/// <summary>Finding ID.</summary>
public string? FindingId { get; init; }
/// <summary>CVE ID.</summary>
public string? CveId { get; init; }
/// <summary>Component.</summary>
public string? Component { get; init; }
/// <summary>Image digest.</summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceSubjectResponse FromSubject(EvidenceSubject subject) => new()
{
Type = subject.Type.ToString(),
FindingId = subject.FindingId,
CveId = subject.CveId,
Component = subject.Component,
ImageDigest = subject.ImageDigest
};
}
/// <summary>
/// Evidence claim response.
/// </summary>
public sealed record EvidenceClaimResponse
{
/// <summary>Claim ID.</summary>
public required string ClaimId { get; init; }
/// <summary>Claim text.</summary>
public required string Text { get; init; }
/// <summary>Claim type.</summary>
public required string Type { get; init; }
/// <summary>Status.</summary>
public required string Status { get; init; }
/// <summary>Confidence score.</summary>
public double Confidence { get; init; }
/// <summary>Evidence IDs.</summary>
public required IReadOnlyList<string> EvidenceIds { get; init; }
/// <summary>Source.</summary>
public string? Source { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceClaimResponse FromClaim(EvidenceClaim claim) => new()
{
ClaimId = claim.ClaimId,
Text = claim.Text,
Type = claim.Type.ToString(),
Status = claim.Status,
Confidence = claim.Confidence,
EvidenceIds = claim.EvidenceIds.ToList(),
Source = claim.Source
};
}
/// <summary>
/// Evidence item response.
/// </summary>
public sealed record EvidenceItemResponse
{
/// <summary>Evidence ID.</summary>
public required string EvidenceId { get; init; }
/// <summary>Evidence type.</summary>
public required string Type { get; init; }
/// <summary>URI.</summary>
public required string Uri { get; init; }
/// <summary>Digest.</summary>
public required string Digest { get; init; }
/// <summary>Collection timestamp.</summary>
public required string CollectedAt { get; init; }
/// <summary>Snapshot.</summary>
public required EvidenceSnapshotResponse Snapshot { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceItemResponse FromItem(EvidenceItem item) => new()
{
EvidenceId = item.EvidenceId,
Type = item.Type.ToString(),
Uri = item.Uri,
Digest = item.Digest,
CollectedAt = item.CollectedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
Snapshot = EvidenceSnapshotResponse.FromSnapshot(item.Snapshot)
};
}
/// <summary>
/// Evidence snapshot response.
/// </summary>
public sealed record EvidenceSnapshotResponse
{
/// <summary>Snapshot type.</summary>
public required string Type { get; init; }
/// <summary>Snapshot data.</summary>
public required IReadOnlyDictionary<string, object?> Data { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceSnapshotResponse FromSnapshot(EvidenceSnapshot snapshot) => new()
{
Type = snapshot.Type,
Data = snapshot.Data
};
}
/// <summary>
/// Evidence pack context response.
/// </summary>
public sealed record EvidencePackContextResponse
{
/// <summary>Tenant ID.</summary>
public string? TenantId { get; init; }
/// <summary>Run ID.</summary>
public string? RunId { get; init; }
/// <summary>Conversation ID.</summary>
public string? ConversationId { get; init; }
/// <summary>User ID.</summary>
public string? UserId { get; init; }
/// <summary>Generator.</summary>
public string? GeneratedBy { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidencePackContextResponse FromContext(EvidencePackContext context) => new()
{
TenantId = context.TenantId,
RunId = context.RunId,
ConversationId = context.ConversationId,
UserId = context.UserId,
GeneratedBy = context.GeneratedBy
};
}
/// <summary>
/// Evidence pack links.
/// </summary>
public sealed record EvidencePackLinks
{
/// <summary>Self link.</summary>
public string? Self { get; init; }
/// <summary>Sign link.</summary>
public string? Sign { get; init; }
/// <summary>Verify link.</summary>
public string? Verify { get; init; }
/// <summary>Export link.</summary>
public string? Export { get; init; }
}
/// <summary>
/// Signed evidence pack response.
/// </summary>
public sealed record SignedEvidencePackResponse
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Signed timestamp.</summary>
public required string SignedAt { get; init; }
/// <summary>Pack content.</summary>
public required EvidencePackResponse Pack { get; init; }
/// <summary>DSSE envelope.</summary>
public required DsseEnvelopeResponse Envelope { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static SignedEvidencePackResponse FromSignedPack(SignedEvidencePack signedPack) => new()
{
PackId = signedPack.Pack.PackId,
SignedAt = signedPack.SignedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
Pack = EvidencePackResponse.FromPack(signedPack.Pack),
Envelope = DsseEnvelopeResponse.FromEnvelope(signedPack.Envelope)
};
}
/// <summary>
/// DSSE envelope response.
/// </summary>
public sealed record DsseEnvelopeResponse
{
/// <summary>Payload type.</summary>
public required string PayloadType { get; init; }
/// <summary>Payload digest.</summary>
public required string PayloadDigest { get; init; }
/// <summary>Signatures.</summary>
public required IReadOnlyList<DsseSignatureResponse> Signatures { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static DsseEnvelopeResponse FromEnvelope(DsseEnvelope envelope) => new()
{
PayloadType = envelope.PayloadType,
PayloadDigest = envelope.PayloadDigest,
Signatures = envelope.Signatures.Select(s => new DsseSignatureResponse
{
KeyId = s.KeyId,
Sig = s.Sig
}).ToList()
};
}
/// <summary>
/// DSSE signature response.
/// </summary>
public sealed record DsseSignatureResponse
{
/// <summary>Key ID.</summary>
public required string KeyId { get; init; }
/// <summary>Signature.</summary>
public required string Sig { get; init; }
}
/// <summary>
/// Evidence pack verification response.
/// </summary>
public sealed record EvidencePackVerificationResponse
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Whether verification passed.</summary>
public bool Valid { get; init; }
/// <summary>Pack digest.</summary>
public string? PackDigest { get; init; }
/// <summary>Signing key ID.</summary>
public string? SignatureKeyId { get; init; }
/// <summary>Issues found.</summary>
public IReadOnlyList<string>? Issues { get; init; }
/// <summary>Evidence resolution results.</summary>
public IReadOnlyList<EvidenceResolutionApiResponse>? EvidenceResolutions { get; init; }
}
/// <summary>
/// Evidence resolution result in API response.
/// </summary>
public sealed record EvidenceResolutionApiResponse
{
/// <summary>Evidence ID.</summary>
public required string EvidenceId { get; init; }
/// <summary>URI.</summary>
public required string Uri { get; init; }
/// <summary>Whether resolved.</summary>
public bool Resolved { get; init; }
/// <summary>Whether digest matches.</summary>
public bool DigestMatches { get; init; }
/// <summary>Error message.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Evidence pack list response.
/// </summary>
public sealed record EvidencePackListResponse
{
/// <summary>Total count.</summary>
public int Count { get; init; }
/// <summary>Pack summaries.</summary>
public required IReadOnlyList<EvidencePackSummary> Packs { get; init; }
}
/// <summary>
/// Evidence pack summary.
/// </summary>
public sealed record EvidencePackSummary
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Created timestamp.</summary>
public required string CreatedAt { get; init; }
/// <summary>Subject type.</summary>
public required string SubjectType { get; init; }
/// <summary>CVE ID if applicable.</summary>
public string? CveId { get; init; }
/// <summary>Number of claims.</summary>
public int ClaimCount { get; init; }
/// <summary>Number of evidence items.</summary>
public int EvidenceCount { get; init; }
/// <summary>
/// Creates summary from domain model.
/// </summary>
public static EvidencePackSummary FromPack(EvidencePack pack) => new()
{
PackId = pack.PackId,
TenantId = pack.TenantId,
CreatedAt = pack.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
SubjectType = pack.Subject.Type.ToString(),
CveId = pack.Subject.CveId,
ClaimCount = pack.Claims.Length,
EvidenceCount = pack.Evidence.Length
};
}
#endregion

View File

@@ -0,0 +1,904 @@
// <copyright file="RunEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.Runs;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
/// <summary>
/// API endpoints for AI investigation runs.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-006
/// </summary>
public static class RunEndpoints
{
/// <summary>
/// Maps run endpoints to the route builder.
/// </summary>
/// <param name="builder">The endpoint route builder.</param>
/// <returns>The route group builder.</returns>
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/runs")
.WithTags("Runs");
group.MapPost("/", CreateRunAsync)
.WithName("CreateRun")
.WithSummary("Creates a new AI investigation run")
.Produces<RunDto>(StatusCodes.Status201Created)
.ProducesValidationProblem();
group.MapGet("/{runId}", GetRunAsync)
.WithName("GetRun")
.WithSummary("Gets a run by ID")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/", QueryRunsAsync)
.WithName("QueryRuns")
.WithSummary("Queries runs with filters")
.Produces<RunQueryResultDto>();
group.MapGet("/{runId}/timeline", GetTimelineAsync)
.WithName("GetRunTimeline")
.WithSummary("Gets the event timeline for a run")
.Produces<ImmutableArray<RunEventDto>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/events", AddEventAsync)
.WithName("AddRunEvent")
.WithSummary("Adds an event to a run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/turns/user", AddUserTurnAsync)
.WithName("AddUserTurn")
.WithSummary("Adds a user turn to the run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/turns/assistant", AddAssistantTurnAsync)
.WithName("AddAssistantTurn")
.WithSummary("Adds an assistant turn to the run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/actions", ProposeActionAsync)
.WithName("ProposeAction")
.WithSummary("Proposes an action in the run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/approval/request", RequestApprovalAsync)
.WithName("RequestApproval")
.WithSummary("Requests approval for pending actions")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/approval/decide", ApproveAsync)
.WithName("ApproveRun")
.WithSummary("Approves or rejects a run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/actions/{actionEventId}/execute", ExecuteActionAsync)
.WithName("ExecuteAction")
.WithSummary("Executes an approved action")
.Produces<RunEventDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/artifacts", AddArtifactAsync)
.WithName("AddArtifact")
.WithSummary("Adds an artifact to the run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/complete", CompleteRunAsync)
.WithName("CompleteRun")
.WithSummary("Completes a run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/cancel", CancelRunAsync)
.WithName("CancelRun")
.WithSummary("Cancels a run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/handoff", HandOffRunAsync)
.WithName("HandOffRun")
.WithSummary("Hands off a run to another user")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/attest", AttestRunAsync)
.WithName("AttestRun")
.WithSummary("Creates an attestation for a completed run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapGet("/active", GetActiveRunsAsync)
.WithName("GetActiveRuns")
.WithSummary("Gets active runs for the current user")
.Produces<ImmutableArray<RunDto>>();
group.MapGet("/pending-approval", GetPendingApprovalAsync)
.WithName("GetPendingApproval")
.WithSummary("Gets runs pending approval")
.Produces<ImmutableArray<RunDto>>();
return group;
}
private static async Task<IResult> CreateRunAsync(
[FromBody] CreateRunRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
var run = await runService.CreateAsync(new CreateRunRequest
{
TenantId = tenantId,
InitiatedBy = userId,
Title = request.Title,
Objective = request.Objective,
Context = request.Context is not null ? MapToContext(request.Context) : null,
Metadata = request.Metadata?.ToImmutableDictionary()
}, ct);
return Results.Created($"/api/v1/runs/{run.RunId}", MapToDto(run));
}
private static async Task<IResult> GetRunAsync(
string runId,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
var run = await runService.GetAsync(tenantId, runId, ct);
if (run is null)
{
return Results.NotFound(new { message = $"Run {runId} not found" });
}
return Results.Ok(MapToDto(run));
}
private static async Task<IResult> QueryRunsAsync(
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromQuery] string? initiatedBy,
[FromQuery] string? cveId,
[FromQuery] string? component,
[FromQuery] string? status,
[FromQuery] int skip = 0,
[FromQuery] int take = 20,
CancellationToken ct = default)
{
tenantId ??= "default";
ImmutableArray<RunStatus>? statuses = null;
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<RunStatus>(status, true, out var parsedStatus))
{
statuses = [parsedStatus];
}
var result = await runService.QueryAsync(new RunQuery
{
TenantId = tenantId,
InitiatedBy = initiatedBy,
CveId = cveId,
Component = component,
Statuses = statuses,
Skip = skip,
Take = take
}, ct);
return Results.Ok(new RunQueryResultDto
{
Runs = result.Runs.Select(MapToDto).ToImmutableArray(),
TotalCount = result.TotalCount,
HasMore = result.HasMore
});
}
private static async Task<IResult> GetTimelineAsync(
string runId,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromQuery] int skip = 0,
[FromQuery] int take = 100,
CancellationToken ct = default)
{
tenantId ??= "default";
var events = await runService.GetTimelineAsync(tenantId, runId, skip, take, ct);
return Results.Ok(events.Select(MapEventToDto).ToImmutableArray());
}
private static async Task<IResult> AddEventAsync(
string runId,
[FromBody] AddEventRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var evt = await runService.AddEventAsync(tenantId, runId, new AddRunEventRequest
{
Type = request.Type,
ActorId = userId,
Content = request.Content,
EvidenceLinks = request.EvidenceLinks,
ParentEventId = request.ParentEventId,
Metadata = request.Metadata?.ToImmutableDictionary()
}, ct);
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> AddUserTurnAsync(
string runId,
[FromBody] AddTurnRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
try
{
var evt = await runService.AddUserTurnAsync(
tenantId, runId, request.Message, userId, request.EvidenceLinks, ct);
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> AddAssistantTurnAsync(
string runId,
[FromBody] AddTurnRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var evt = await runService.AddAssistantTurnAsync(
tenantId, runId, request.Message, request.EvidenceLinks, ct);
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> ProposeActionAsync(
string runId,
[FromBody] ProposeActionRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var evt = await runService.ProposeActionAsync(tenantId, runId, new ProposeActionRequest
{
ActionType = request.ActionType,
Subject = request.Subject,
Rationale = request.Rationale,
RequiresApproval = request.RequiresApproval,
Parameters = request.Parameters?.ToImmutableDictionary(),
EvidenceLinks = request.EvidenceLinks
}, ct);
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> RequestApprovalAsync(
string runId,
[FromBody] RequestApprovalDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.RequestApprovalAsync(
tenantId, runId, [.. request.Approvers], request.Reason, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> ApproveAsync(
string runId,
[FromBody] ApprovalDecisionDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
try
{
var run = await runService.ApproveAsync(
tenantId, runId, request.Approved, userId, request.Reason, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> ExecuteActionAsync(
string runId,
string actionEventId,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var evt = await runService.ExecuteActionAsync(tenantId, runId, actionEventId, ct);
return Results.Ok(MapEventToDto(evt));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> AddArtifactAsync(
string runId,
[FromBody] AddArtifactRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.AddArtifactAsync(tenantId, runId, new RunArtifact
{
ArtifactId = request.ArtifactId ?? Guid.NewGuid().ToString("N"),
Type = request.Type,
Name = request.Name,
Description = request.Description,
CreatedAt = DateTimeOffset.UtcNow,
ContentDigest = request.ContentDigest,
ContentSize = request.ContentSize,
MediaType = request.MediaType,
StorageUri = request.StorageUri,
IsInline = request.IsInline,
InlineContent = request.InlineContent,
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
}, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> CompleteRunAsync(
string runId,
[FromBody] CompleteRunRequestDto? request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.CompleteAsync(tenantId, runId, request?.Summary, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> CancelRunAsync(
string runId,
[FromBody] CancelRunRequestDto? request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.CancelAsync(tenantId, runId, request?.Reason, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> HandOffRunAsync(
string runId,
[FromBody] HandOffRequestDto request,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.HandOffAsync(tenantId, runId, request.ToUserId, request.Message, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { message = ex.Message });
}
}
private static async Task<IResult> AttestRunAsync(
string runId,
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.AttestAsync(tenantId, runId, ct);
return Results.Ok(MapToDto(run));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
}
private static async Task<IResult> GetActiveRunsAsync(
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
var result = await runService.QueryAsync(new RunQuery
{
TenantId = tenantId,
InitiatedBy = userId,
Statuses = [RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval],
Take = 50
}, ct);
return Results.Ok(result.Runs.Select(MapToDto).ToImmutableArray());
}
private static async Task<IResult> GetPendingApprovalAsync(
[FromServices] IRunService runService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
var result = await runService.QueryAsync(new RunQuery
{
TenantId = tenantId,
Statuses = [RunStatus.PendingApproval],
Take = 50
}, ct);
return Results.Ok(result.Runs.Select(MapToDto).ToImmutableArray());
}
private static RunDto MapToDto(Run run) => new()
{
RunId = run.RunId,
TenantId = run.TenantId,
InitiatedBy = run.InitiatedBy,
Title = run.Title,
Objective = run.Objective,
Status = run.Status.ToString(),
CreatedAt = run.CreatedAt,
UpdatedAt = run.UpdatedAt,
CompletedAt = run.CompletedAt,
EventCount = run.Events.Length,
ArtifactCount = run.Artifacts.Length,
ContentDigest = run.ContentDigest,
IsAttested = run.Attestation is not null,
Context = MapContextToDto(run.Context),
Approval = run.Approval is not null ? MapApprovalToDto(run.Approval) : null,
Metadata = run.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
private static RunEventDto MapEventToDto(RunEvent evt) => new()
{
EventId = evt.EventId,
Type = evt.Type.ToString(),
Timestamp = evt.Timestamp,
ActorId = evt.ActorId,
SequenceNumber = evt.SequenceNumber,
ParentEventId = evt.ParentEventId,
EvidenceLinkCount = evt.EvidenceLinks.Length,
Metadata = evt.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
private static RunContextDto MapContextToDto(RunContext context) => new()
{
FocusedCveId = context.FocusedCveId,
FocusedComponent = context.FocusedComponent,
SbomDigest = context.SbomDigest,
ImageReference = context.ImageReference,
Tags = [.. context.Tags],
IsOpsMemoryEnriched = context.OpsMemory?.IsEnriched ?? false
};
private static ApprovalInfoDto MapApprovalToDto(ApprovalInfo approval) => new()
{
Required = approval.Required,
Approvers = [.. approval.Approvers],
Approved = approval.Approved,
ApprovedBy = approval.ApprovedBy,
ApprovedAt = approval.ApprovedAt,
Reason = approval.Reason
};
private static RunContext MapToContext(RunContextDto dto) => new()
{
FocusedCveId = dto.FocusedCveId,
FocusedComponent = dto.FocusedComponent,
SbomDigest = dto.SbomDigest,
ImageReference = dto.ImageReference,
Tags = [.. dto.Tags ?? []]
};
}
// DTOs
/// <summary>DTO for creating a run.</summary>
public sealed record CreateRunRequestDto
{
/// <summary>Gets the run title.</summary>
public required string Title { get; init; }
/// <summary>Gets the run objective.</summary>
public string? Objective { get; init; }
/// <summary>Gets the context.</summary>
public RunContextDto? Context { get; init; }
/// <summary>Gets metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>DTO for run context.</summary>
public sealed record RunContextDto
{
/// <summary>Gets the focused CVE ID.</summary>
public string? FocusedCveId { get; init; }
/// <summary>Gets the focused component.</summary>
public string? FocusedComponent { get; init; }
/// <summary>Gets the SBOM digest.</summary>
public string? SbomDigest { get; init; }
/// <summary>Gets the image reference.</summary>
public string? ImageReference { get; init; }
/// <summary>Gets the tags.</summary>
public List<string>? Tags { get; init; }
/// <summary>Gets whether OpsMemory enrichment was applied.</summary>
public bool IsOpsMemoryEnriched { get; init; }
}
/// <summary>DTO for a run.</summary>
public sealed record RunDto
{
/// <summary>Gets the run ID.</summary>
public required string RunId { get; init; }
/// <summary>Gets the tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Gets the initiator.</summary>
public required string InitiatedBy { get; init; }
/// <summary>Gets the title.</summary>
public required string Title { get; init; }
/// <summary>Gets the objective.</summary>
public string? Objective { get; init; }
/// <summary>Gets the status.</summary>
public required string Status { get; init; }
/// <summary>Gets the created timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Gets the updated timestamp.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>Gets the completed timestamp.</summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>Gets the event count.</summary>
public int EventCount { get; init; }
/// <summary>Gets the artifact count.</summary>
public int ArtifactCount { get; init; }
/// <summary>Gets the content digest.</summary>
public string? ContentDigest { get; init; }
/// <summary>Gets whether the run is attested.</summary>
public bool IsAttested { get; init; }
/// <summary>Gets the context.</summary>
public RunContextDto? Context { get; init; }
/// <summary>Gets the approval info.</summary>
public ApprovalInfoDto? Approval { get; init; }
/// <summary>Gets metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>DTO for run event.</summary>
public sealed record RunEventDto
{
/// <summary>Gets the event ID.</summary>
public required string EventId { get; init; }
/// <summary>Gets the event type.</summary>
public required string Type { get; init; }
/// <summary>Gets the timestamp.</summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Gets the actor ID.</summary>
public string? ActorId { get; init; }
/// <summary>Gets the sequence number.</summary>
public int SequenceNumber { get; init; }
/// <summary>Gets the parent event ID.</summary>
public string? ParentEventId { get; init; }
/// <summary>Gets the evidence link count.</summary>
public int EvidenceLinkCount { get; init; }
/// <summary>Gets metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>DTO for approval info.</summary>
public sealed record ApprovalInfoDto
{
/// <summary>Gets whether approval is required.</summary>
public bool Required { get; init; }
/// <summary>Gets the approvers.</summary>
public List<string> Approvers { get; init; } = [];
/// <summary>Gets whether approved.</summary>
public bool? Approved { get; init; }
/// <summary>Gets who approved.</summary>
public string? ApprovedBy { get; init; }
/// <summary>Gets when approved.</summary>
public DateTimeOffset? ApprovedAt { get; init; }
/// <summary>Gets the reason.</summary>
public string? Reason { get; init; }
}
/// <summary>DTO for query results.</summary>
public sealed record RunQueryResultDto
{
/// <summary>Gets the runs.</summary>
public required ImmutableArray<RunDto> Runs { get; init; }
/// <summary>Gets the total count.</summary>
public required int TotalCount { get; init; }
/// <summary>Gets whether there are more results.</summary>
public bool HasMore { get; init; }
}
/// <summary>DTO for adding an event.</summary>
public sealed record AddEventRequestDto
{
/// <summary>Gets the event type.</summary>
public required RunEventType Type { get; init; }
/// <summary>Gets the content.</summary>
public RunEventContent? Content { get; init; }
/// <summary>Gets evidence links.</summary>
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
/// <summary>Gets the parent event ID.</summary>
public string? ParentEventId { get; init; }
/// <summary>Gets metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>DTO for adding a turn.</summary>
public sealed record AddTurnRequestDto
{
/// <summary>Gets the message.</summary>
public required string Message { get; init; }
/// <summary>Gets evidence links.</summary>
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
}
/// <summary>DTO for proposing an action.</summary>
public sealed record ProposeActionRequestDto
{
/// <summary>Gets the action type.</summary>
public required string ActionType { get; init; }
/// <summary>Gets the subject.</summary>
public string? Subject { get; init; }
/// <summary>Gets the rationale.</summary>
public string? Rationale { get; init; }
/// <summary>Gets whether approval is required.</summary>
public bool RequiresApproval { get; init; } = true;
/// <summary>Gets the parameters.</summary>
public Dictionary<string, string>? Parameters { get; init; }
/// <summary>Gets evidence links.</summary>
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
}
/// <summary>DTO for requesting approval.</summary>
public sealed record RequestApprovalDto
{
/// <summary>Gets the approvers.</summary>
public required List<string> Approvers { get; init; }
/// <summary>Gets the reason.</summary>
public string? Reason { get; init; }
}
/// <summary>DTO for approval decision.</summary>
public sealed record ApprovalDecisionDto
{
/// <summary>Gets whether approved.</summary>
public required bool Approved { get; init; }
/// <summary>Gets the reason.</summary>
public string? Reason { get; init; }
}
/// <summary>DTO for adding an artifact.</summary>
public sealed record AddArtifactRequestDto
{
/// <summary>Gets the artifact ID.</summary>
public string? ArtifactId { get; init; }
/// <summary>Gets the artifact type.</summary>
public required ArtifactType Type { get; init; }
/// <summary>Gets the name.</summary>
public required string Name { get; init; }
/// <summary>Gets the description.</summary>
public string? Description { get; init; }
/// <summary>Gets the content digest.</summary>
public required string ContentDigest { get; init; }
/// <summary>Gets the content size.</summary>
public long ContentSize { get; init; }
/// <summary>Gets the media type.</summary>
public required string MediaType { get; init; }
/// <summary>Gets the storage URI.</summary>
public string? StorageUri { get; init; }
/// <summary>Gets whether inline.</summary>
public bool IsInline { get; init; }
/// <summary>Gets inline content.</summary>
public string? InlineContent { get; init; }
/// <summary>Gets metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>DTO for completing a run.</summary>
public sealed record CompleteRunRequestDto
{
/// <summary>Gets the summary.</summary>
public string? Summary { get; init; }
}
/// <summary>DTO for canceling a run.</summary>
public sealed record CancelRunRequestDto
{
/// <summary>Gets the reason.</summary>
public string? Reason { get; init; }
}
/// <summary>DTO for hand off.</summary>
public sealed record HandOffRequestDto
{
/// <summary>Gets the target user ID.</summary>
public required string ToUserId { get; init; }
/// <summary>Gets the message.</summary>
public string? Message { get; init; }
}

View File

@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.Evidence.Pack;
using StellaOps.AdvisoryAI.Diagnostics;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.Hosting;
@@ -56,6 +57,9 @@ builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationG
builder.Services.AddAiAttestationServices();
builder.Services.AddInMemoryAiAttestationStore();
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
builder.Services.AddEvidencePack();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
@@ -188,6 +192,9 @@ app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
// AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
app.MapAttestationEndpoints();
// Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
app.MapEvidencePackEndpoints();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);

View File

@@ -15,5 +15,7 @@
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<!-- AI Attestations (Sprint: SPRINT_20260109_011_001) -->
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005) -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,151 @@
// <copyright file="ActionAuditLedger.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// In-memory action audit ledger for development and testing.
/// In production, this would use PostgreSQL with proper indexing.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
/// </summary>
internal sealed class ActionAuditLedger : IActionAuditLedger
{
private readonly ConcurrentDictionary<string, ActionAuditEntry> _entries = new();
private readonly ILogger<ActionAuditLedger> _logger;
private readonly AuditLedgerOptions _options;
public ActionAuditLedger(
IOptions<AuditLedgerOptions> options,
ILogger<ActionAuditLedger> logger)
{
_options = options?.Value ?? new AuditLedgerOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
_entries[entry.EntryId] = entry;
_logger.LogDebug(
"Recorded audit entry {EntryId}: {ActionType} by {Actor} -> {Outcome}",
entry.EntryId, entry.ActionType, entry.Actor, entry.Outcome);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
ActionAuditQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
var entries = _entries.Values.AsEnumerable();
// Apply filters
if (!string.IsNullOrEmpty(query.TenantId))
{
entries = entries.Where(e => e.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.ActionType))
{
entries = entries.Where(e => e.ActionType.Equals(query.ActionType, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.Actor))
{
entries = entries.Where(e => e.Actor.Equals(query.Actor, StringComparison.OrdinalIgnoreCase));
}
if (query.Outcome.HasValue)
{
entries = entries.Where(e => e.Outcome == query.Outcome.Value);
}
if (!string.IsNullOrEmpty(query.RunId))
{
entries = entries.Where(e => e.RunId != null && e.RunId.Equals(query.RunId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.CveId))
{
entries = entries.Where(e => e.CveId != null && e.CveId.Equals(query.CveId, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(query.ImageDigest))
{
entries = entries.Where(e => e.ImageDigest != null && e.ImageDigest.Equals(query.ImageDigest, StringComparison.OrdinalIgnoreCase));
}
if (query.FromTimestamp.HasValue)
{
entries = entries.Where(e => e.Timestamp >= query.FromTimestamp.Value);
}
if (query.ToTimestamp.HasValue)
{
entries = entries.Where(e => e.Timestamp < query.ToTimestamp.Value);
}
// Order by timestamp descending, apply pagination
var result = entries
.OrderByDescending(e => e.Timestamp)
.Skip(query.Offset)
.Take(query.Limit)
.ToImmutableArray();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<ActionAuditEntry?> GetAsync(
string entryId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(entryId);
_entries.TryGetValue(entryId, out var entry);
return Task.FromResult(entry);
}
/// <inheritdoc />
public Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
string runId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(runId);
var entries = _entries.Values
.Where(e => e.RunId != null && e.RunId.Equals(runId, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => e.Timestamp)
.ToImmutableArray();
return Task.FromResult(entries);
}
}
/// <summary>
/// Configuration options for the audit ledger.
/// </summary>
public sealed class AuditLedgerOptions
{
/// <summary>
/// Days to retain audit entries.
/// </summary>
public int RetentionDays { get; set; } = 365;
/// <summary>
/// Whether audit logging is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,135 @@
// <copyright file="ActionDefinition.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Defines the metadata and constraints for an action type.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
/// </summary>
public sealed record ActionDefinition
{
/// <summary>
/// The unique action type identifier (e.g., "approve", "quarantine").
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Human-readable display name.
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Description of what this action does.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Role required to execute this action.
/// </summary>
public required string RequiredRole { get; init; }
/// <summary>
/// Risk level of this action for policy decisions.
/// </summary>
public required ActionRiskLevel RiskLevel { get; init; }
/// <summary>
/// Whether this action is idempotent (safe to retry).
/// </summary>
public required bool IsIdempotent { get; init; }
/// <summary>
/// Whether this action supports rollback/compensation.
/// </summary>
public required bool HasCompensation { get; init; }
/// <summary>
/// Action type for compensation/rollback, if supported.
/// </summary>
public string? CompensationActionType { get; init; }
/// <summary>
/// Parameters accepted by this action.
/// </summary>
public ImmutableArray<ActionParameterDefinition> Parameters { get; init; } =
ImmutableArray<ActionParameterDefinition>.Empty;
/// <summary>
/// Environments where this action can be executed.
/// Empty means all environments.
/// </summary>
public ImmutableArray<string> AllowedEnvironments { get; init; } =
ImmutableArray<string>.Empty;
/// <summary>
/// Tags for categorization.
/// </summary>
public ImmutableArray<string> Tags { get; init; } =
ImmutableArray<string>.Empty;
}
/// <summary>
/// Risk levels for actions, affecting policy decisions and approval requirements.
/// </summary>
public enum ActionRiskLevel
{
/// <summary>
/// Read-only, informational actions.
/// </summary>
Low = 0,
/// <summary>
/// Creates records, sends notifications.
/// </summary>
Medium = 1,
/// <summary>
/// Modifies security posture.
/// </summary>
High = 2,
/// <summary>
/// Production blockers, quarantine operations.
/// </summary>
Critical = 3
}
/// <summary>
/// Definition of an action parameter.
/// </summary>
public sealed record ActionParameterDefinition
{
/// <summary>
/// Parameter name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Parameter type (string, int, bool, etc.).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Whether this parameter is required.
/// </summary>
public required bool Required { get; init; }
/// <summary>
/// Description of the parameter.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Default value if not provided.
/// </summary>
public string? DefaultValue { get; init; }
/// <summary>
/// Validation regex pattern.
/// </summary>
public string? ValidationPattern { get; init; }
}

View File

@@ -0,0 +1,456 @@
// <copyright file="ActionExecutor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Executes AI-proposed actions with policy gate integration, idempotency, and audit logging.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-007
/// </summary>
internal sealed class ActionExecutor : IActionExecutor
{
private readonly IActionPolicyGate _policyGate;
private readonly IActionRegistry _actionRegistry;
private readonly IIdempotencyHandler _idempotencyHandler;
private readonly IApprovalWorkflowAdapter _approvalAdapter;
private readonly IActionAuditLedger _auditLedger;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<ActionExecutor> _logger;
private readonly ActionExecutorOptions _options;
public ActionExecutor(
IActionPolicyGate policyGate,
IActionRegistry actionRegistry,
IIdempotencyHandler idempotencyHandler,
IApprovalWorkflowAdapter approvalAdapter,
IActionAuditLedger auditLedger,
TimeProvider timeProvider,
IGuidGenerator guidGenerator,
IOptions<ActionExecutorOptions> options,
ILogger<ActionExecutor> logger)
{
_policyGate = policyGate ?? throw new ArgumentNullException(nameof(policyGate));
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
_idempotencyHandler = idempotencyHandler ?? throw new ArgumentNullException(nameof(idempotencyHandler));
_approvalAdapter = approvalAdapter ?? throw new ArgumentNullException(nameof(approvalAdapter));
_auditLedger = auditLedger ?? throw new ArgumentNullException(nameof(auditLedger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
_options = options?.Value ?? new ActionExecutorOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<ActionExecutionResult> ExecuteAsync(
ActionProposal proposal,
ActionPolicyDecision decision,
ActionContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(proposal);
ArgumentNullException.ThrowIfNull(decision);
ArgumentNullException.ThrowIfNull(context);
var executionId = _guidGenerator.NewGuid().ToString();
var startedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Executing action {ActionType} (execution: {ExecutionId}) for user {UserId}",
proposal.ActionType, executionId, context.UserId);
// 1. Check idempotency first
if (_options.EnableIdempotency)
{
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
var idempotencyCheck = await _idempotencyHandler.CheckAsync(idempotencyKey, cancellationToken);
if (idempotencyCheck.AlreadyExecuted)
{
_logger.LogInformation(
"Action {ActionType} skipped due to idempotency (previous execution: {PreviousId})",
proposal.ActionType, idempotencyCheck.PreviousResult?.ExecutionId);
await RecordAuditEntryAsync(
executionId, proposal, context, ActionAuditOutcome.IdempotentSkipped,
null, null, cancellationToken);
return idempotencyCheck.PreviousResult!;
}
}
// 2. Evaluate policy if not already evaluated
var policyDecision = decision;
if (decision.Decision == PolicyDecisionKind.Indeterminate)
{
policyDecision = await _policyGate.EvaluateAsync(proposal, context, cancellationToken);
}
// 3. Handle based on policy decision
ActionExecutionResult result;
switch (policyDecision.Decision)
{
case PolicyDecisionKind.Allow:
result = await ExecuteImmediatelyAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
break;
case PolicyDecisionKind.AllowWithApproval:
result = await ExecuteWithApprovalAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
break;
case PolicyDecisionKind.Deny:
result = CreateDeniedResult(executionId, proposal, policyDecision, startedAt);
await RecordAuditEntryAsync(
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
policyDecision, null, cancellationToken);
break;
case PolicyDecisionKind.DenyWithOverride:
result = CreateDeniedWithOverrideResult(executionId, proposal, policyDecision, startedAt);
await RecordAuditEntryAsync(
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
policyDecision, null, cancellationToken);
break;
default:
throw new InvalidOperationException($"Unexpected policy decision: {policyDecision.Decision}");
}
// 4. Record idempotency if execution was successful
if (_options.EnableIdempotency && result.Outcome == ActionExecutionOutcome.Success)
{
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
await _idempotencyHandler.RecordExecutionAsync(idempotencyKey, result, context, cancellationToken);
}
return result;
}
/// <inheritdoc />
public async Task<ActionRollbackResult> RollbackAsync(
string executionId,
ActionContext context,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(executionId);
ArgumentNullException.ThrowIfNull(context);
// In a real implementation, this would:
// 1. Look up the original execution
// 2. Find the compensation action
// 3. Execute the compensation
// 4. Record the rollback
_logger.LogInformation(
"Rollback requested for execution {ExecutionId}",
executionId);
// Stub implementation
return new ActionRollbackResult
{
Success = false,
Message = "Rollback not yet implemented",
Error = new ActionError
{
Code = "NOT_IMPLEMENTED",
Message = "Rollback functionality is not yet implemented"
}
};
}
/// <inheritdoc />
public Task<ActionExecutionStatus?> GetStatusAsync(
string executionId,
CancellationToken cancellationToken)
{
// In a real implementation, this would look up execution status from storage
return Task.FromResult<ActionExecutionStatus?>(null);
}
/// <inheritdoc />
public ImmutableArray<ActionTypeInfo> GetSupportedActionTypes()
{
return _actionRegistry.GetAllActions()
.Select(a => new ActionTypeInfo
{
Type = a.ActionType,
DisplayName = a.DisplayName,
Description = a.Description,
Category = GetActionCategory(a),
Parameters = a.Parameters.Select(p => new ActionParameterInfo
{
Name = p.Name,
DisplayName = p.Name,
Description = p.Description,
IsRequired = p.Required,
Type = p.Type,
DefaultValue = p.DefaultValue
}).ToImmutableArray(),
RequiredPermission = a.RequiredRole,
SupportsRollback = a.HasCompensation,
IsDestructive = a.RiskLevel >= ActionRiskLevel.High
})
.ToImmutableArray();
}
private async Task<ActionExecutionResult> ExecuteImmediatelyAsync(
string executionId,
ActionProposal proposal,
ActionContext context,
ActionPolicyDecision decision,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Executing action {ActionType} immediately (policy: {PolicyId})",
proposal.ActionType, decision.PolicyId);
try
{
// Perform the actual action execution
// In a real implementation, this would dispatch to specific action handlers
var result = await PerformActionAsync(executionId, proposal, context, startedAt, cancellationToken);
await RecordAuditEntryAsync(
executionId, proposal, context,
result.Outcome == ActionExecutionOutcome.Success
? ActionAuditOutcome.Executed
: ActionAuditOutcome.ExecutionFailed,
decision, result.Error?.Message, cancellationToken);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Action execution failed for {ActionType}", proposal.ActionType);
await RecordAuditEntryAsync(
executionId, proposal, context, ActionAuditOutcome.ExecutionFailed,
decision, ex.Message, cancellationToken);
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.Failed,
Message = $"Execution failed: {ex.Message}",
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = false,
Error = new ActionError
{
Code = "EXECUTION_FAILED",
Message = ex.Message,
IsRetryable = true
}
};
}
}
private async Task<ActionExecutionResult> ExecuteWithApprovalAsync(
string executionId,
ActionProposal proposal,
ActionContext context,
ActionPolicyDecision decision,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Action {ActionType} requires approval (policy: {PolicyId})",
proposal.ActionType, decision.PolicyId);
// Create approval request
var approvalRequest = await _approvalAdapter.CreateApprovalRequestAsync(
proposal, decision, context, cancellationToken);
await RecordAuditEntryAsync(
executionId, proposal, context, ActionAuditOutcome.ApprovalRequested,
decision, null, cancellationToken, approvalRequest.RequestId);
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.PendingApproval,
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
StartedAt = startedAt,
CompletedAt = null, // Not completed yet
CanRollback = false,
OutputData = new Dictionary<string, string>
{
["approvalRequestId"] = approvalRequest.RequestId,
["approvalWorkflowId"] = approvalRequest.WorkflowId
}.ToImmutableDictionary()
};
}
private async Task<ActionExecutionResult> PerformActionAsync(
string executionId,
ActionProposal proposal,
ActionContext context,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
// Get action definition for rollback capability
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
var canRollback = actionDef?.HasCompensation ?? false;
// In a real implementation, this would dispatch to specific action handlers
// For now, simulate successful execution
_logger.LogInformation(
"Performed action {ActionType} with parameters: {Parameters}",
proposal.ActionType,
string.Join(", ", proposal.Parameters.Select(p => $"{p.Key}={p.Value}")));
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.Success,
Message = $"Action {proposal.ActionType} executed successfully",
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = canRollback,
OutputData = new Dictionary<string, string>
{
["actionType"] = proposal.ActionType,
["status"] = "completed"
}.ToImmutableDictionary()
};
}
private ActionExecutionResult CreateDeniedResult(
string executionId,
ActionProposal proposal,
ActionPolicyDecision decision,
DateTimeOffset startedAt)
{
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.Failed,
Message = $"Action denied by policy: {decision.Reason}",
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = false,
Error = new ActionError
{
Code = "POLICY_DENIED",
Message = decision.Reason ?? "Action denied by policy",
IsRetryable = false
}
};
}
private ActionExecutionResult CreateDeniedWithOverrideResult(
string executionId,
ActionProposal proposal,
ActionPolicyDecision decision,
DateTimeOffset startedAt)
{
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.Failed,
Message = $"Action denied (override available): {decision.Reason}",
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = false,
Error = new ActionError
{
Code = "POLICY_DENIED_OVERRIDE_AVAILABLE",
Message = decision.Reason ?? "Action denied by policy",
Details = "An administrator can override this decision",
IsRetryable = false
},
OutputData = new Dictionary<string, string>
{
["overrideAvailable"] = "true",
["policyId"] = decision.PolicyId ?? ""
}.ToImmutableDictionary()
};
}
private async Task RecordAuditEntryAsync(
string executionId,
ActionProposal proposal,
ActionContext context,
ActionAuditOutcome outcome,
ActionPolicyDecision? decision,
string? errorMessage,
CancellationToken cancellationToken,
string? approvalRequestId = null)
{
var entry = new ActionAuditEntry
{
EntryId = _guidGenerator.NewGuid().ToString(),
TenantId = context.TenantId,
Timestamp = _timeProvider.GetUtcNow(),
ActionType = proposal.ActionType,
Actor = context.UserId,
Outcome = outcome,
RunId = context.RunId,
FindingId = context.FindingId,
CveId = context.CveId,
ImageDigest = context.ImageDigest,
PolicyId = decision?.PolicyId,
PolicyResult = decision?.Decision,
ApprovalRequestId = approvalRequestId,
Parameters = proposal.Parameters,
ExecutionId = executionId,
ErrorMessage = errorMessage
};
await _auditLedger.RecordAsync(entry, cancellationToken);
}
private static string? GetActionCategory(ActionDefinition action)
{
if (action.Tags.Contains("cve", StringComparer.OrdinalIgnoreCase) ||
action.Tags.Contains("vex", StringComparer.OrdinalIgnoreCase))
{
return "Vulnerability Management";
}
if (action.Tags.Contains("container", StringComparer.OrdinalIgnoreCase))
{
return "Container Security";
}
if (action.Tags.Contains("report", StringComparer.OrdinalIgnoreCase) ||
action.Tags.Contains("export", StringComparer.OrdinalIgnoreCase))
{
return "Reporting";
}
if (action.Tags.Contains("notification", StringComparer.OrdinalIgnoreCase))
{
return "Communication";
}
return "General";
}
}
/// <summary>
/// Configuration options for action execution.
/// </summary>
public sealed class ActionExecutorOptions
{
/// <summary>
/// Whether idempotency checking is enabled.
/// </summary>
public bool EnableIdempotency { get; set; } = true;
/// <summary>
/// Whether audit logging is enabled.
/// </summary>
public bool EnableAuditLogging { get; set; } = true;
/// <summary>
/// Default timeout for action execution.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(5);
}

View File

@@ -0,0 +1,352 @@
// <copyright file="ActionPolicyGate.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Evaluates action proposals against K4 lattice policy rules.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
/// </summary>
internal sealed class ActionPolicyGate : IActionPolicyGate
{
private readonly IActionRegistry _actionRegistry;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ActionPolicyGate> _logger;
private readonly ActionPolicyOptions _options;
public ActionPolicyGate(
IActionRegistry actionRegistry,
TimeProvider timeProvider,
IOptions<ActionPolicyOptions> options,
ILogger<ActionPolicyGate> logger)
{
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? new ActionPolicyOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<ActionPolicyDecision> EvaluateAsync(
ActionProposal proposal,
ActionContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(proposal);
ArgumentNullException.ThrowIfNull(context);
_logger.LogDebug(
"Evaluating policy for action {ActionType} by user {UserId} in tenant {TenantId}",
proposal.ActionType, context.UserId, context.TenantId);
// Get action definition
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
if (actionDef is null)
{
return Task.FromResult(CreateDenyDecision(
$"Unknown action type: {proposal.ActionType}",
"action-validation",
allowOverride: false));
}
// Validate parameters
var paramValidation = _actionRegistry.ValidateParameters(proposal.ActionType, proposal.Parameters);
if (!paramValidation.IsValid)
{
return Task.FromResult(CreateDenyDecision(
$"Invalid parameters: {string.Join(", ", paramValidation.Errors)}",
"parameter-validation",
allowOverride: false));
}
// Check required role
if (!HasRequiredRole(context.UserRoles, actionDef.RequiredRole))
{
return Task.FromResult(CreateDenyDecision(
$"Missing required role: {actionDef.RequiredRole}",
"role-check",
allowOverride: false));
}
// Check environment restrictions
if (actionDef.AllowedEnvironments.Length > 0 &&
!actionDef.AllowedEnvironments.Contains(context.Environment, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(CreateDenyDecision(
$"Action not allowed in environment: {context.Environment}",
"environment-check",
allowOverride: true));
}
// Evaluate based on risk level and K4 context
var decision = EvaluateRiskLevel(actionDef, context);
_logger.LogInformation(
"Policy decision for {ActionType}: {Decision} (policy: {PolicyId})",
proposal.ActionType, decision.Decision, decision.PolicyId);
return Task.FromResult(decision);
}
/// <inheritdoc />
public Task<PolicyExplanation> ExplainAsync(
ActionPolicyDecision decision,
CancellationToken cancellationToken)
{
var details = new List<string>();
if (!string.IsNullOrEmpty(decision.Reason))
{
details.Add(decision.Reason);
}
if (!string.IsNullOrEmpty(decision.K4Position))
{
details.Add($"K4 lattice position: {decision.K4Position}");
}
if (!string.IsNullOrEmpty(decision.VexStatus))
{
details.Add($"VEX status: {decision.VexStatus}");
}
var suggestedActions = new List<string>();
switch (decision.Decision)
{
case PolicyDecisionKind.Deny:
suggestedActions.Add("Contact your security administrator to request elevated permissions");
break;
case PolicyDecisionKind.DenyWithOverride:
suggestedActions.Add("Request an admin override if you have business justification");
break;
case PolicyDecisionKind.AllowWithApproval:
suggestedActions.Add($"Request approval from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}");
break;
}
var explanation = new PolicyExplanation
{
Summary = GenerateSummary(decision),
Details = details.ToImmutableArray(),
PolicyReferences = decision.PolicyId is not null
? ImmutableArray.Create(new PolicyReference
{
PolicyId = decision.PolicyId,
Name = GetPolicyName(decision.PolicyId)
})
: ImmutableArray<PolicyReference>.Empty,
SuggestedActions = suggestedActions.ToImmutableArray()
};
return Task.FromResult(explanation);
}
/// <inheritdoc />
public Task<IdempotencyCheckResult> CheckIdempotencyAsync(
ActionProposal proposal,
ActionContext context,
CancellationToken cancellationToken)
{
// Delegate to IdempotencyHandler - this is a stub for interface compliance
// The actual check is done in ActionExecutor
return Task.FromResult(new IdempotencyCheckResult
{
WasExecuted = false
});
}
private ActionPolicyDecision EvaluateRiskLevel(ActionDefinition actionDef, ActionContext context)
{
// Map risk level to policy decision
return actionDef.RiskLevel switch
{
ActionRiskLevel.Low => CreateAllowDecision(actionDef, context),
ActionRiskLevel.Medium => EvaluateMediumRisk(actionDef, context),
ActionRiskLevel.High => EvaluateHighRisk(actionDef, context),
ActionRiskLevel.Critical => EvaluateCriticalRisk(actionDef, context),
_ => CreateDenyDecision("Unknown risk level", "risk-evaluation", allowOverride: true)
};
}
private ActionPolicyDecision CreateAllowDecision(ActionDefinition actionDef, ActionContext context)
{
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.Allow,
PolicyId = "low-risk-auto-allow",
Reason = "Low-risk action allowed automatically"
};
}
private ActionPolicyDecision EvaluateMediumRisk(ActionDefinition actionDef, ActionContext context)
{
// Check if user has elevated role
if (HasRequiredRole(context.UserRoles, "security-lead") ||
HasRequiredRole(context.UserRoles, "admin"))
{
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.Allow,
PolicyId = "medium-risk-elevated-role",
Reason = "Medium-risk action allowed for elevated role"
};
}
// Require team lead approval
return CreateApprovalDecision(
actionDef,
context,
"medium-risk-approval",
"Medium-risk action requires team lead approval",
[new RequiredApprover { Type = ApproverType.Role, Identifier = "team-lead" }]);
}
private ActionPolicyDecision EvaluateHighRisk(ActionDefinition actionDef, ActionContext context)
{
// Check if admin
if (HasRequiredRole(context.UserRoles, "admin"))
{
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.Allow,
PolicyId = "high-risk-admin",
Reason = "High-risk action allowed for admin"
};
}
// Require security lead approval
return CreateApprovalDecision(
actionDef,
context,
"high-risk-approval",
"High-risk action requires security lead approval",
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
}
private ActionPolicyDecision EvaluateCriticalRisk(ActionDefinition actionDef, ActionContext context)
{
// Critical actions always require multi-party approval
// Even admins need CISO sign-off for critical actions in production
if (context.Environment.Equals("production", StringComparison.OrdinalIgnoreCase))
{
return CreateApprovalDecision(
actionDef,
context,
"critical-risk-production",
"Critical action in production requires CISO and security lead approval",
[
new RequiredApprover { Type = ApproverType.Role, Identifier = "ciso" },
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }
]);
}
// Non-production: just security lead
return CreateApprovalDecision(
actionDef,
context,
"critical-risk-non-prod",
"Critical action requires security lead approval",
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
}
private ActionPolicyDecision CreateApprovalDecision(
ActionDefinition actionDef,
ActionContext context,
string policyId,
string reason,
ImmutableArray<RequiredApprover> approvers)
{
var timeout = actionDef.RiskLevel == ActionRiskLevel.Critical
? TimeSpan.FromHours(_options.CriticalTimeoutHours)
: TimeSpan.FromHours(_options.DefaultTimeoutHours);
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.AllowWithApproval,
PolicyId = policyId,
Reason = reason,
RequiredApprovers = approvers,
ApprovalWorkflowId = $"action-approval-{actionDef.RiskLevel.ToString().ToLowerInvariant()}",
ExpiresAt = _timeProvider.GetUtcNow().Add(timeout)
};
}
private static ActionPolicyDecision CreateDenyDecision(string reason, string policyId, bool allowOverride)
{
return new ActionPolicyDecision
{
Decision = allowOverride ? PolicyDecisionKind.DenyWithOverride : PolicyDecisionKind.Deny,
PolicyId = policyId,
Reason = reason
};
}
private static bool HasRequiredRole(ImmutableArray<string> userRoles, string requiredRole)
{
// Admin role can do everything
if (userRoles.Contains("admin", StringComparer.OrdinalIgnoreCase))
{
return true;
}
return userRoles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase);
}
private static string GenerateSummary(ActionPolicyDecision decision)
{
return decision.Decision switch
{
PolicyDecisionKind.Allow => "Action is allowed and can proceed immediately.",
PolicyDecisionKind.AllowWithApproval => "Action requires approval before execution.",
PolicyDecisionKind.Deny => "Action is denied by policy.",
PolicyDecisionKind.DenyWithOverride => "Action is denied but can be overridden by an administrator.",
PolicyDecisionKind.Indeterminate => "Unable to determine policy decision.",
_ => "Unknown policy decision."
};
}
private static string GetPolicyName(string policyId)
{
return policyId switch
{
"low-risk-auto-allow" => "Low Risk Auto-Allow Policy",
"medium-risk-elevated-role" => "Medium Risk Elevated Role Policy",
"medium-risk-approval" => "Medium Risk Approval Policy",
"high-risk-admin" => "High Risk Admin Policy",
"high-risk-approval" => "High Risk Approval Policy",
"critical-risk-production" => "Critical Risk Production Policy",
"critical-risk-non-prod" => "Critical Risk Non-Production Policy",
"role-check" => "Role Authorization Policy",
"environment-check" => "Environment Restriction Policy",
"action-validation" => "Action Validation Policy",
"parameter-validation" => "Parameter Validation Policy",
_ => policyId
};
}
}
/// <summary>
/// Configuration options for action policy evaluation.
/// </summary>
public sealed class ActionPolicyOptions
{
/// <summary>
/// Default timeout in hours for approval requests.
/// </summary>
public int DefaultTimeoutHours { get; set; } = 4;
/// <summary>
/// Timeout in hours for critical risk approval requests.
/// </summary>
public int CriticalTimeoutHours { get; set; } = 24;
/// <summary>
/// Whether to enable K4 lattice integration.
/// </summary>
public bool EnableK4Integration { get; set; } = true;
}

View File

@@ -0,0 +1,433 @@
// <copyright file="ActionRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Default implementation of action registry with built-in action definitions.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
/// </summary>
internal sealed partial class ActionRegistry : IActionRegistry
{
private readonly FrozenDictionary<string, ActionDefinition> _actions;
public ActionRegistry()
{
_actions = CreateBuiltInActions().ToFrozenDictionary(a => a.ActionType, StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc />
public ActionDefinition? GetAction(string actionType)
{
ArgumentNullException.ThrowIfNull(actionType);
return _actions.GetValueOrDefault(actionType);
}
/// <inheritdoc />
public ImmutableArray<ActionDefinition> GetAllActions() =>
_actions.Values.ToImmutableArray();
/// <inheritdoc />
public ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel) =>
_actions.Values.Where(a => a.RiskLevel == riskLevel).ToImmutableArray();
/// <inheritdoc />
public ImmutableArray<ActionDefinition> GetActionsByTag(string tag) =>
_actions.Values.Where(a => a.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
/// <inheritdoc />
public ActionParameterValidationResult ValidateParameters(
string actionType,
ImmutableDictionary<string, string> parameters)
{
var definition = GetAction(actionType);
if (definition is null)
{
return ActionParameterValidationResult.Failure($"Unknown action type: {actionType}");
}
var errors = new List<string>();
// Check required parameters
foreach (var param in definition.Parameters.Where(p => p.Required))
{
if (!parameters.ContainsKey(param.Name) || string.IsNullOrWhiteSpace(parameters[param.Name]))
{
errors.Add($"Missing required parameter: {param.Name}");
}
}
// Validate parameter patterns
foreach (var param in definition.Parameters.Where(p => p.ValidationPattern is not null))
{
if (parameters.TryGetValue(param.Name, out var value) && !string.IsNullOrEmpty(value))
{
if (!Regex.IsMatch(value, param.ValidationPattern!))
{
errors.Add($"Parameter '{param.Name}' does not match pattern: {param.ValidationPattern}");
}
}
}
return errors.Count == 0
? ActionParameterValidationResult.Success
: ActionParameterValidationResult.Failure(errors.ToArray());
}
private static IEnumerable<ActionDefinition> CreateBuiltInActions()
{
// CVE/Finding Actions
yield return new ActionDefinition
{
ActionType = "approve",
DisplayName = "Approve Risk",
Description = "Accept the risk for a CVE finding with documented justification",
RequiredRole = "security-analyst",
RiskLevel = ActionRiskLevel.High,
IsIdempotent = true,
HasCompensation = true,
CompensationActionType = "revoke_approval",
Parameters =
[
new ActionParameterDefinition
{
Name = "cve_id",
Type = "string",
Required = true,
Description = "CVE identifier",
ValidationPattern = CveIdPattern().ToString()
},
new ActionParameterDefinition
{
Name = "justification",
Type = "string",
Required = true,
Description = "Risk acceptance justification"
},
new ActionParameterDefinition
{
Name = "expires_days",
Type = "int",
Required = false,
Description = "Days until approval expires",
DefaultValue = "90"
}
],
Tags = ["cve", "risk", "vex"]
};
yield return new ActionDefinition
{
ActionType = "revoke_approval",
DisplayName = "Revoke Risk Approval",
Description = "Revoke a previously approved risk acceptance",
RequiredRole = "security-analyst",
RiskLevel = ActionRiskLevel.Medium,
IsIdempotent = true,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "cve_id",
Type = "string",
Required = true,
Description = "CVE identifier",
ValidationPattern = CveIdPattern().ToString()
},
new ActionParameterDefinition
{
Name = "reason",
Type = "string",
Required = true,
Description = "Reason for revocation"
}
],
Tags = ["cve", "risk", "vex"]
};
yield return new ActionDefinition
{
ActionType = "quarantine",
DisplayName = "Quarantine Image",
Description = "Block an image from deployment due to critical vulnerability",
RequiredRole = "security-lead",
RiskLevel = ActionRiskLevel.Critical,
IsIdempotent = true,
HasCompensation = true,
CompensationActionType = "release_quarantine",
Parameters =
[
new ActionParameterDefinition
{
Name = "image_digest",
Type = "string",
Required = true,
Description = "Image digest to quarantine",
ValidationPattern = ImageDigestPattern().ToString()
},
new ActionParameterDefinition
{
Name = "reason",
Type = "string",
Required = true,
Description = "Reason for quarantine"
},
new ActionParameterDefinition
{
Name = "cve_ids",
Type = "string",
Required = false,
Description = "Comma-separated CVE IDs"
}
],
Tags = ["container", "security", "deployment"]
};
yield return new ActionDefinition
{
ActionType = "release_quarantine",
DisplayName = "Release from Quarantine",
Description = "Release a previously quarantined image",
RequiredRole = "security-lead",
RiskLevel = ActionRiskLevel.High,
IsIdempotent = true,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "image_digest",
Type = "string",
Required = true,
Description = "Image digest to release",
ValidationPattern = ImageDigestPattern().ToString()
},
new ActionParameterDefinition
{
Name = "justification",
Type = "string",
Required = true,
Description = "Justification for release"
}
],
Tags = ["container", "security", "deployment"]
};
yield return new ActionDefinition
{
ActionType = "defer",
DisplayName = "Defer Finding",
Description = "Defer remediation of a finding to a later date",
RequiredRole = "security-analyst",
RiskLevel = ActionRiskLevel.Low,
IsIdempotent = true,
HasCompensation = true,
CompensationActionType = "undefer",
Parameters =
[
new ActionParameterDefinition
{
Name = "finding_id",
Type = "string",
Required = true,
Description = "Finding identifier"
},
new ActionParameterDefinition
{
Name = "defer_days",
Type = "int",
Required = true,
Description = "Days to defer"
},
new ActionParameterDefinition
{
Name = "reason",
Type = "string",
Required = true,
Description = "Reason for deferral"
}
],
Tags = ["finding", "triage"]
};
yield return new ActionDefinition
{
ActionType = "undefer",
DisplayName = "Undefer Finding",
Description = "Remove deferral from a finding",
RequiredRole = "security-analyst",
RiskLevel = ActionRiskLevel.Low,
IsIdempotent = true,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "finding_id",
Type = "string",
Required = true,
Description = "Finding identifier"
}
],
Tags = ["finding", "triage"]
};
// VEX Actions
yield return new ActionDefinition
{
ActionType = "create_vex",
DisplayName = "Create VEX Statement",
Description = "Create a VEX statement for a CVE",
RequiredRole = "security-analyst",
RiskLevel = ActionRiskLevel.Medium,
IsIdempotent = false,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "cve_id",
Type = "string",
Required = true,
Description = "CVE identifier",
ValidationPattern = CveIdPattern().ToString()
},
new ActionParameterDefinition
{
Name = "status",
Type = "string",
Required = true,
Description = "VEX status (not_affected, affected, under_investigation, fixed)"
},
new ActionParameterDefinition
{
Name = "justification",
Type = "string",
Required = false,
Description = "Justification for not_affected status"
},
new ActionParameterDefinition
{
Name = "impact_statement",
Type = "string",
Required = false,
Description = "Impact statement for affected status"
}
],
Tags = ["vex", "compliance"]
};
// Report Actions
yield return new ActionDefinition
{
ActionType = "generate_manifest",
DisplayName = "Generate Security Manifest",
Description = "Generate a security manifest for an image",
RequiredRole = "viewer",
RiskLevel = ActionRiskLevel.Low,
IsIdempotent = true,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "image_digest",
Type = "string",
Required = true,
Description = "Image digest",
ValidationPattern = ImageDigestPattern().ToString()
},
new ActionParameterDefinition
{
Name = "format",
Type = "string",
Required = false,
Description = "Output format (json, pdf)",
DefaultValue = "json"
}
],
Tags = ["report", "compliance"]
};
yield return new ActionDefinition
{
ActionType = "export_sbom",
DisplayName = "Export SBOM",
Description = "Export SBOM in specified format",
RequiredRole = "viewer",
RiskLevel = ActionRiskLevel.Low,
IsIdempotent = true,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "image_digest",
Type = "string",
Required = true,
Description = "Image digest",
ValidationPattern = ImageDigestPattern().ToString()
},
new ActionParameterDefinition
{
Name = "format",
Type = "string",
Required = false,
Description = "SBOM format (spdx-json, cyclonedx-json)",
DefaultValue = "spdx-json"
}
],
Tags = ["sbom", "export", "compliance"]
};
// Notification Actions
yield return new ActionDefinition
{
ActionType = "notify_team",
DisplayName = "Notify Team",
Description = "Send notification to a team channel",
RequiredRole = "security-analyst",
RiskLevel = ActionRiskLevel.Medium,
IsIdempotent = false,
HasCompensation = false,
Parameters =
[
new ActionParameterDefinition
{
Name = "channel",
Type = "string",
Required = true,
Description = "Notification channel"
},
new ActionParameterDefinition
{
Name = "message",
Type = "string",
Required = true,
Description = "Message content"
},
new ActionParameterDefinition
{
Name = "priority",
Type = "string",
Required = false,
Description = "Priority (low, medium, high, critical)",
DefaultValue = "medium"
}
],
Tags = ["notification", "communication"]
};
}
[GeneratedRegex(@"^CVE-\d{4}-\d{4,}$", RegexOptions.IgnoreCase)]
private static partial Regex CveIdPattern();
[GeneratedRegex(@"^sha256:[a-fA-F0-9]{64}$")]
private static partial Regex ImageDigestPattern();
}

View File

@@ -0,0 +1,275 @@
// <copyright file="ApprovalWorkflowAdapter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// In-memory approval workflow adapter for development and testing.
/// In production, this would integrate with ReviewWorkflowService.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
/// </summary>
internal sealed class ApprovalWorkflowAdapter : IApprovalWorkflowAdapter
{
private readonly ConcurrentDictionary<string, ApprovalRequestState> _requests = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<ApprovalWorkflowAdapter> _logger;
public ApprovalWorkflowAdapter(
TimeProvider timeProvider,
IGuidGenerator guidGenerator,
ILogger<ApprovalWorkflowAdapter> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<ApprovalRequest> CreateApprovalRequestAsync(
ActionProposal proposal,
ActionPolicyDecision decision,
ActionContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(proposal);
ArgumentNullException.ThrowIfNull(decision);
ArgumentNullException.ThrowIfNull(context);
var requestId = _guidGenerator.NewGuid().ToString();
var now = _timeProvider.GetUtcNow();
var timeout = decision.ExpiresAt.HasValue
? decision.ExpiresAt.Value - now
: TimeSpan.FromHours(4);
var request = new ApprovalRequest
{
RequestId = requestId,
WorkflowId = decision.ApprovalWorkflowId ?? "default",
TenantId = context.TenantId,
RequesterId = context.UserId,
RequiredApprovers = decision.RequiredApprovers,
Timeout = timeout,
Payload = new ApprovalPayload
{
ActionType = proposal.ActionType,
ActionLabel = proposal.Label,
Parameters = proposal.Parameters,
RunId = context.RunId,
FindingId = context.FindingId,
PolicyReason = decision.Reason
},
CreatedAt = now
};
var state = new ApprovalRequestState
{
Request = request,
State = ApprovalState.Pending,
Approvals = ImmutableArray<ApprovalEntry>.Empty
};
_requests[requestId] = state;
_logger.LogInformation(
"Created approval request {RequestId} for action {ActionType} by user {UserId}",
requestId, proposal.ActionType, context.UserId);
return Task.FromResult(request);
}
/// <inheritdoc />
public Task<ApprovalStatus?> GetApprovalStatusAsync(
string requestId,
CancellationToken cancellationToken)
{
if (!_requests.TryGetValue(requestId, out var state))
{
return Task.FromResult<ApprovalStatus?>(null);
}
// Check for expiration
var now = _timeProvider.GetUtcNow();
if (state.State == ApprovalState.Pending && now >= state.Request.ExpiresAt)
{
state.State = ApprovalState.Expired;
state.UpdatedAt = now;
}
var status = new ApprovalStatus
{
RequestId = requestId,
State = state.State,
Approvals = state.Approvals,
CreatedAt = state.Request.CreatedAt,
UpdatedAt = state.UpdatedAt,
ExpiresAt = state.Request.ExpiresAt
};
return Task.FromResult<ApprovalStatus?>(status);
}
/// <inheritdoc />
public async Task<ApprovalResult> WaitForApprovalAsync(
string requestId,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
try
{
while (!cts.IsCancellationRequested)
{
var status = await GetApprovalStatusAsync(requestId, cts.Token);
if (status is null)
{
return new ApprovalResult
{
Approved = false,
DenialReason = "Approval request not found"
};
}
switch (status.State)
{
case ApprovalState.Approved:
var approvalEntry = status.Approvals.LastOrDefault();
return new ApprovalResult
{
Approved = true,
ApproverId = approvalEntry?.ApproverId,
DecidedAt = approvalEntry?.DecidedAt,
Comments = approvalEntry?.Comments
};
case ApprovalState.Denied:
var denialEntry = status.Approvals.LastOrDefault(a => !a.Approved);
return new ApprovalResult
{
Approved = false,
ApproverId = denialEntry?.ApproverId,
DecidedAt = denialEntry?.DecidedAt,
Comments = denialEntry?.Comments,
DenialReason = denialEntry?.Comments ?? "Request denied"
};
case ApprovalState.Expired:
return new ApprovalResult
{
Approved = false,
TimedOut = true,
DenialReason = "Approval request expired"
};
case ApprovalState.Cancelled:
return new ApprovalResult
{
Approved = false,
Cancelled = true,
DenialReason = "Approval request was cancelled"
};
case ApprovalState.Pending:
// Continue waiting
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
break;
}
}
}
catch (OperationCanceledException)
{
// Timeout or cancellation
}
return new ApprovalResult
{
Approved = false,
TimedOut = true,
DenialReason = "Timed out waiting for approval"
};
}
/// <inheritdoc />
public Task CancelApprovalRequestAsync(
string requestId,
string reason,
CancellationToken cancellationToken)
{
if (_requests.TryGetValue(requestId, out var state) && state.State == ApprovalState.Pending)
{
state.State = ApprovalState.Cancelled;
state.UpdatedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Cancelled approval request {RequestId}: {Reason}",
requestId, reason);
}
return Task.CompletedTask;
}
/// <summary>
/// Records an approval decision (used for testing and external approval callbacks).
/// </summary>
public void RecordApproval(string requestId, string approverId, bool approved, string? comments = null)
{
if (!_requests.TryGetValue(requestId, out var state))
{
throw new InvalidOperationException($"Approval request not found: {requestId}");
}
if (state.State != ApprovalState.Pending)
{
throw new InvalidOperationException($"Request {requestId} is not pending");
}
var entry = new ApprovalEntry
{
ApproverId = approverId,
Approved = approved,
Comments = comments,
DecidedAt = _timeProvider.GetUtcNow()
};
state.Approvals = state.Approvals.Add(entry);
state.UpdatedAt = entry.DecidedAt;
if (!approved)
{
state.State = ApprovalState.Denied;
}
else
{
// Check if all required approvals are met
var approvedRoles = state.Approvals
.Where(a => a.Approved)
.Select(a => a.ApproverId)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Simplified check: any required approver approving is sufficient
// In production, would check against actual role membership
state.State = ApprovalState.Approved;
}
_logger.LogInformation(
"Recorded {Decision} for request {RequestId} by {ApproverId}",
approved ? "approval" : "denial", requestId, approverId);
}
private sealed class ApprovalRequestState
{
public required ApprovalRequest Request { get; init; }
public ApprovalState State { get; set; }
public ImmutableArray<ApprovalEntry> Approvals { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}
}

View File

@@ -0,0 +1,276 @@
// <copyright file="IActionAuditLedger.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Audit ledger for recording all action attempts and outcomes.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
/// </summary>
public interface IActionAuditLedger
{
/// <summary>
/// Records an audit entry for an action.
/// </summary>
/// <param name="entry">The audit entry to record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken);
/// <summary>
/// Queries audit entries.
/// </summary>
/// <param name="query">The query criteria.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Matching audit entries.</returns>
Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
ActionAuditQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Gets a specific audit entry by ID.
/// </summary>
/// <param name="entryId">The entry ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The audit entry or null if not found.</returns>
Task<ActionAuditEntry?> GetAsync(
string entryId,
CancellationToken cancellationToken);
/// <summary>
/// Gets audit entries for a specific run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Audit entries for the run.</returns>
Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
string runId,
CancellationToken cancellationToken);
}
/// <summary>
/// An audit entry for an action attempt.
/// </summary>
public sealed record ActionAuditEntry
{
/// <summary>
/// Unique entry identifier.
/// </summary>
public required string EntryId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the action was attempted.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Type of action attempted.
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// User who attempted the action.
/// </summary>
public required string Actor { get; init; }
/// <summary>
/// Outcome of the action attempt.
/// </summary>
public required ActionAuditOutcome Outcome { get; init; }
/// <summary>
/// Associated AI run ID.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Associated finding ID.
/// </summary>
public string? FindingId { get; init; }
/// <summary>
/// Associated CVE ID.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Associated image digest.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Policy that evaluated the action.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// Policy decision result.
/// </summary>
public PolicyDecisionKind? PolicyResult { get; init; }
/// <summary>
/// Approval request ID if approval was required.
/// </summary>
public string? ApprovalRequestId { get; init; }
/// <summary>
/// User who approved the action.
/// </summary>
public string? ApproverId { get; init; }
/// <summary>
/// Action parameters.
/// </summary>
public ImmutableDictionary<string, string> Parameters { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Execution result ID if executed.
/// </summary>
public string? ExecutionId { get; init; }
/// <summary>
/// Result digest for verification.
/// </summary>
public string? ResultDigest { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Attestation digest if attested.
/// </summary>
public string? AttestationDigest { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Outcome of an action attempt.
/// </summary>
public enum ActionAuditOutcome
{
/// <summary>
/// Action was successfully executed.
/// </summary>
Executed,
/// <summary>
/// Action was denied by policy.
/// </summary>
DeniedByPolicy,
/// <summary>
/// Approval was requested.
/// </summary>
ApprovalRequested,
/// <summary>
/// Action was approved and executed.
/// </summary>
Approved,
/// <summary>
/// Approval was denied.
/// </summary>
ApprovalDenied,
/// <summary>
/// Approval request timed out.
/// </summary>
ApprovalTimedOut,
/// <summary>
/// Execution failed.
/// </summary>
ExecutionFailed,
/// <summary>
/// Action was skipped due to idempotency.
/// </summary>
IdempotentSkipped,
/// <summary>
/// Action was rolled back.
/// </summary>
RolledBack,
/// <summary>
/// Action validation failed.
/// </summary>
ValidationFailed
}
/// <summary>
/// Query criteria for audit entries.
/// </summary>
public sealed record ActionAuditQuery
{
/// <summary>
/// Filter by tenant.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Filter by action type.
/// </summary>
public string? ActionType { get; init; }
/// <summary>
/// Filter by actor (user).
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Filter by outcome.
/// </summary>
public ActionAuditOutcome? Outcome { get; init; }
/// <summary>
/// Filter by run ID.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Filter by CVE ID.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Filter by image digest.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Start of time range (inclusive).
/// </summary>
public DateTimeOffset? FromTimestamp { get; init; }
/// <summary>
/// End of time range (exclusive).
/// </summary>
public DateTimeOffset? ToTimestamp { get; init; }
/// <summary>
/// Maximum number of results.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Offset for pagination.
/// </summary>
public int Offset { get; init; } = 0;
}

View File

@@ -0,0 +1,349 @@
// <copyright file="IActionExecutor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Executes AI-proposed actions after policy gate approval.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
/// </summary>
public interface IActionExecutor
{
/// <summary>
/// Executes an action after policy gate approval.
/// </summary>
/// <param name="proposal">The approved action proposal.</param>
/// <param name="decision">The policy decision that approved the action.</param>
/// <param name="context">The execution context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The execution result.</returns>
Task<ActionExecutionResult> ExecuteAsync(
ActionProposal proposal,
ActionPolicyDecision decision,
ActionContext context,
CancellationToken cancellationToken);
/// <summary>
/// Rolls back a previously executed action if supported.
/// </summary>
/// <param name="executionId">The execution ID to rollback.</param>
/// <param name="context">The context for rollback.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rollback result.</returns>
Task<ActionRollbackResult> RollbackAsync(
string executionId,
ActionContext context,
CancellationToken cancellationToken);
/// <summary>
/// Gets the status of an action execution.
/// </summary>
/// <param name="executionId">The execution ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The current execution status.</returns>
Task<ActionExecutionStatus?> GetStatusAsync(
string executionId,
CancellationToken cancellationToken);
/// <summary>
/// Lists available action types supported by this executor.
/// </summary>
/// <returns>The available action types.</returns>
ImmutableArray<ActionTypeInfo> GetSupportedActionTypes();
}
/// <summary>
/// Result of action execution.
/// </summary>
public sealed record ActionExecutionResult
{
/// <summary>
/// Unique identifier for this execution.
/// </summary>
public required string ExecutionId { get; init; }
/// <summary>
/// Outcome of the execution.
/// </summary>
public required ActionExecutionOutcome Outcome { get; init; }
/// <summary>
/// Human-readable message about the execution.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Output data from the action.
/// </summary>
public ImmutableDictionary<string, string> OutputData { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// When execution started.
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// When execution completed (null if still running).
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Duration of the execution.
/// </summary>
public TimeSpan? Duration => CompletedAt.HasValue
? CompletedAt.Value - StartedAt
: null;
/// <summary>
/// Whether this action can be rolled back.
/// </summary>
public bool CanRollback { get; init; }
/// <summary>
/// Error details if execution failed.
/// </summary>
public ActionError? Error { get; init; }
/// <summary>
/// Related artifact IDs created or modified by this action.
/// </summary>
public ImmutableArray<string> AffectedArtifacts { get; init; } =
ImmutableArray<string>.Empty;
}
/// <summary>
/// Outcome of action execution.
/// </summary>
public enum ActionExecutionOutcome
{
/// <summary>
/// Action executed successfully.
/// </summary>
Success,
/// <summary>
/// Action partially completed.
/// </summary>
PartialSuccess,
/// <summary>
/// Action failed.
/// </summary>
Failed,
/// <summary>
/// Action was cancelled.
/// </summary>
Cancelled,
/// <summary>
/// Action execution timed out.
/// </summary>
Timeout,
/// <summary>
/// Action was skipped due to idempotency (already executed).
/// </summary>
Skipped,
/// <summary>
/// Action is pending approval.
/// </summary>
PendingApproval,
/// <summary>
/// Action is currently executing.
/// </summary>
InProgress
}
/// <summary>
/// Current status of an action execution.
/// </summary>
public sealed record ActionExecutionStatus
{
/// <summary>
/// The execution ID.
/// </summary>
public required string ExecutionId { get; init; }
/// <summary>
/// Current outcome/state.
/// </summary>
public required ActionExecutionOutcome Outcome { get; init; }
/// <summary>
/// Progress percentage if known (0-100).
/// </summary>
public int? ProgressPercent { get; init; }
/// <summary>
/// Current status message.
/// </summary>
public string? StatusMessage { get; init; }
/// <summary>
/// When status was last updated.
/// </summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Estimated completion time if known.
/// </summary>
public DateTimeOffset? EstimatedCompletionAt { get; init; }
}
/// <summary>
/// Describes an error that occurred during action execution.
/// </summary>
public sealed record ActionError
{
/// <summary>
/// Error code for programmatic handling.
/// </summary>
public required string Code { get; init; }
/// <summary>
/// Human-readable error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Detailed error information.
/// </summary>
public string? Details { get; init; }
/// <summary>
/// Whether the error is retryable.
/// </summary>
public bool IsRetryable { get; init; }
/// <summary>
/// Suggested wait time before retry.
/// </summary>
public TimeSpan? RetryAfter { get; init; }
/// <summary>
/// Inner error if this is a wrapper.
/// </summary>
public ActionError? InnerError { get; init; }
}
/// <summary>
/// Result of rolling back an action.
/// </summary>
public sealed record ActionRollbackResult
{
/// <summary>
/// Whether the rollback was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Message about the rollback.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Error if rollback failed.
/// </summary>
public ActionError? Error { get; init; }
/// <summary>
/// When rollback completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Information about an available action type.
/// </summary>
public sealed record ActionTypeInfo
{
/// <summary>
/// The action type identifier.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Human-readable name.
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Description of what this action does.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Category for grouping actions.
/// </summary>
public string? Category { get; init; }
/// <summary>
/// Required parameters for this action type.
/// </summary>
public ImmutableArray<ActionParameterInfo> Parameters { get; init; } =
ImmutableArray<ActionParameterInfo>.Empty;
/// <summary>
/// Required permission to execute this action.
/// </summary>
public string? RequiredPermission { get; init; }
/// <summary>
/// Whether this action supports rollback.
/// </summary>
public bool SupportsRollback { get; init; }
/// <summary>
/// Whether this action is destructive (requires extra confirmation).
/// </summary>
public bool IsDestructive { get; init; }
}
/// <summary>
/// Information about an action parameter.
/// </summary>
public sealed record ActionParameterInfo
{
/// <summary>
/// Parameter name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Human-readable display name.
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Description of the parameter.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Whether this parameter is required.
/// </summary>
public bool IsRequired { get; init; }
/// <summary>
/// Parameter type (string, integer, boolean, etc.).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Default value if not specified.
/// </summary>
public string? DefaultValue { get; init; }
/// <summary>
/// Valid values for enum-like parameters.
/// </summary>
public ImmutableArray<string> AllowedValues { get; init; } =
ImmutableArray<string>.Empty;
}

View File

@@ -0,0 +1,358 @@
// <copyright file="IActionPolicyGate.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Evaluates whether AI-proposed actions are allowed by policy.
/// Integrates with K4 lattice for VEX-aware decisions and approval workflows.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-001
/// </summary>
public interface IActionPolicyGate
{
/// <summary>
/// Evaluates whether an action is allowed by policy.
/// </summary>
/// <param name="proposal">The action proposal from the AI.</param>
/// <param name="context">The execution context including tenant, user, roles, environment.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The policy decision with any required approvals.</returns>
Task<ActionPolicyDecision> EvaluateAsync(
ActionProposal proposal,
ActionContext context,
CancellationToken cancellationToken);
/// <summary>
/// Gets a human-readable explanation for a policy decision.
/// </summary>
/// <param name="decision">The decision to explain.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Human-readable explanation with policy references.</returns>
Task<PolicyExplanation> ExplainAsync(
ActionPolicyDecision decision,
CancellationToken cancellationToken);
/// <summary>
/// Checks if an action has already been executed (idempotency check).
/// </summary>
/// <param name="proposal">The action proposal.</param>
/// <param name="context">The execution context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the action was already executed with the same parameters.</returns>
Task<IdempotencyCheckResult> CheckIdempotencyAsync(
ActionProposal proposal,
ActionContext context,
CancellationToken cancellationToken);
}
/// <summary>
/// Context for action policy evaluation.
/// </summary>
public sealed record ActionContext
{
/// <summary>
/// Tenant identifier for multi-tenancy.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// User identifier who initiated the action.
/// </summary>
public required string UserId { get; init; }
/// <summary>
/// User's roles/permissions.
/// </summary>
public required ImmutableArray<string> UserRoles { get; init; }
/// <summary>
/// Target environment (production, staging, development, etc.).
/// </summary>
public required string Environment { get; init; }
/// <summary>
/// Associated AI run ID, if any.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Associated finding ID for remediation actions.
/// </summary>
public string? FindingId { get; init; }
/// <summary>
/// CVE ID if this is a vulnerability-related action.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Image digest if this is a container-related action.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Optional correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Additional metadata for policy evaluation.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// An action proposed by the AI system.
/// </summary>
public sealed record ActionProposal
{
/// <summary>
/// Unique identifier for this proposal.
/// </summary>
public required string ProposalId { get; init; }
/// <summary>
/// Type of action (e.g., "approve", "quarantine", "create_vex").
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Human-readable label for the action.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Action parameters.
/// </summary>
public required ImmutableDictionary<string, string> Parameters { get; init; }
/// <summary>
/// When the proposal was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the proposal expires (null = never).
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Idempotency key for deduplication.
/// </summary>
public string? IdempotencyKey { get; init; }
}
/// <summary>
/// Result of policy gate evaluation.
/// </summary>
public sealed record ActionPolicyDecision
{
/// <summary>
/// The decision outcome.
/// </summary>
public required PolicyDecisionKind Decision { get; init; }
/// <summary>
/// Reference to the policy that made this decision.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// Brief reason for the decision.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Required approvers if decision is AllowWithApproval.
/// </summary>
public ImmutableArray<RequiredApprover> RequiredApprovers { get; init; } =
ImmutableArray<RequiredApprover>.Empty;
/// <summary>
/// Approval workflow ID if approval is required.
/// </summary>
public string? ApprovalWorkflowId { get; init; }
/// <summary>
/// K4 lattice position used in the decision.
/// </summary>
public string? K4Position { get; init; }
/// <summary>
/// VEX status that influenced the decision, if any.
/// </summary>
public string? VexStatus { get; init; }
/// <summary>
/// Severity level assigned by policy.
/// </summary>
public int? SeverityLevel { get; init; }
/// <summary>
/// When this decision expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Additional decision metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Kinds of policy decisions.
/// </summary>
public enum PolicyDecisionKind
{
/// <summary>
/// Action is allowed and can execute immediately.
/// </summary>
Allow,
/// <summary>
/// Action is allowed but requires approval workflow.
/// </summary>
AllowWithApproval,
/// <summary>
/// Action is denied by policy.
/// </summary>
Deny,
/// <summary>
/// Action is denied but admin can override.
/// </summary>
DenyWithOverride,
/// <summary>
/// Decision could not be made (missing context).
/// </summary>
Indeterminate
}
/// <summary>
/// Describes a required approver for AllowWithApproval decisions.
/// </summary>
public sealed record RequiredApprover
{
/// <summary>
/// Type of approver requirement.
/// </summary>
public required ApproverType Type { get; init; }
/// <summary>
/// Identifier (user ID, role name, or group name).
/// </summary>
public required string Identifier { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Types of approval requirements.
/// </summary>
public enum ApproverType
{
/// <summary>
/// Specific user must approve.
/// </summary>
User,
/// <summary>
/// Any user with this role can approve.
/// </summary>
Role,
/// <summary>
/// Any member of this group can approve.
/// </summary>
Group
}
/// <summary>
/// Human-readable explanation of a policy decision.
/// </summary>
public sealed record PolicyExplanation
{
/// <summary>
/// Natural language summary of the decision.
/// </summary>
public required string Summary { get; init; }
/// <summary>
/// Detailed explanation points.
/// </summary>
public required ImmutableArray<string> Details { get; init; }
/// <summary>
/// References to policies that were evaluated.
/// </summary>
public ImmutableArray<PolicyReference> PolicyReferences { get; init; } =
ImmutableArray<PolicyReference>.Empty;
/// <summary>
/// Suggested next steps for the user.
/// </summary>
public ImmutableArray<string> SuggestedActions { get; init; } =
ImmutableArray<string>.Empty;
}
/// <summary>
/// Reference to a specific policy.
/// </summary>
public sealed record PolicyReference
{
/// <summary>
/// Policy identifier.
/// </summary>
public required string PolicyId { get; init; }
/// <summary>
/// Policy name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Rule within the policy that matched.
/// </summary>
public string? RuleId { get; init; }
/// <summary>
/// Link to policy documentation.
/// </summary>
public string? DocumentationUrl { get; init; }
}
/// <summary>
/// Result of idempotency check.
/// </summary>
public sealed record IdempotencyCheckResult
{
/// <summary>
/// Whether the action was previously executed.
/// </summary>
public required bool WasExecuted { get; init; }
/// <summary>
/// Previous execution ID if executed.
/// </summary>
public string? PreviousExecutionId { get; init; }
/// <summary>
/// When the action was previously executed.
/// </summary>
public DateTimeOffset? ExecutedAt { get; init; }
/// <summary>
/// Result of the previous execution.
/// </summary>
public string? PreviousResult { get; init; }
}

View File

@@ -0,0 +1,78 @@
// <copyright file="IActionRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Registry of available action types and their definitions.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
/// </summary>
public interface IActionRegistry
{
/// <summary>
/// Gets the definition for an action type.
/// </summary>
/// <param name="actionType">The action type identifier.</param>
/// <returns>The definition or null if not found.</returns>
ActionDefinition? GetAction(string actionType);
/// <summary>
/// Gets all registered action definitions.
/// </summary>
/// <returns>All action definitions.</returns>
ImmutableArray<ActionDefinition> GetAllActions();
/// <summary>
/// Gets actions by risk level.
/// </summary>
/// <param name="riskLevel">The risk level to filter by.</param>
/// <returns>Actions matching the risk level.</returns>
ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel);
/// <summary>
/// Gets actions by tag.
/// </summary>
/// <param name="tag">The tag to filter by.</param>
/// <returns>Actions with the specified tag.</returns>
ImmutableArray<ActionDefinition> GetActionsByTag(string tag);
/// <summary>
/// Validates action parameters against the definition.
/// </summary>
/// <param name="actionType">The action type.</param>
/// <param name="parameters">The parameters to validate.</param>
/// <returns>Validation result.</returns>
ActionParameterValidationResult ValidateParameters(
string actionType,
ImmutableDictionary<string, string> parameters);
}
/// <summary>
/// Result of parameter validation.
/// </summary>
public sealed record ActionParameterValidationResult
{
/// <summary>
/// Whether validation passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Validation errors if any.
/// </summary>
public ImmutableArray<string> Errors { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static ActionParameterValidationResult Success => new() { IsValid = true };
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static ActionParameterValidationResult Failure(params string[] errors) =>
new() { IsValid = false, Errors = errors.ToImmutableArray() };
}

View File

@@ -0,0 +1,283 @@
// <copyright file="IApprovalWorkflowAdapter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Adapter for integrating with approval workflow systems.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
/// </summary>
public interface IApprovalWorkflowAdapter
{
/// <summary>
/// Creates an approval request for an action.
/// </summary>
/// <param name="proposal">The action proposal.</param>
/// <param name="decision">The policy decision requiring approval.</param>
/// <param name="context">The action context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created approval request.</returns>
Task<ApprovalRequest> CreateApprovalRequestAsync(
ActionProposal proposal,
ActionPolicyDecision decision,
ActionContext context,
CancellationToken cancellationToken);
/// <summary>
/// Gets the status of an approval request.
/// </summary>
/// <param name="requestId">The request ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The approval status or null if not found.</returns>
Task<ApprovalStatus?> GetApprovalStatusAsync(
string requestId,
CancellationToken cancellationToken);
/// <summary>
/// Waits for an approval decision with timeout.
/// </summary>
/// <param name="requestId">The request ID.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The approval result.</returns>
Task<ApprovalResult> WaitForApprovalAsync(
string requestId,
TimeSpan timeout,
CancellationToken cancellationToken);
/// <summary>
/// Cancels a pending approval request.
/// </summary>
/// <param name="requestId">The request ID.</param>
/// <param name="reason">Cancellation reason.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task CancelApprovalRequestAsync(
string requestId,
string reason,
CancellationToken cancellationToken);
}
/// <summary>
/// An approval request for an action.
/// </summary>
public sealed record ApprovalRequest
{
/// <summary>
/// Unique request identifier.
/// </summary>
public required string RequestId { get; init; }
/// <summary>
/// Associated workflow ID.
/// </summary>
public required string WorkflowId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// User who requested the action.
/// </summary>
public required string RequesterId { get; init; }
/// <summary>
/// Required approvers.
/// </summary>
public required ImmutableArray<RequiredApprover> RequiredApprovers { get; init; }
/// <summary>
/// Request timeout.
/// </summary>
public required TimeSpan Timeout { get; init; }
/// <summary>
/// Payload containing action details.
/// </summary>
public required ApprovalPayload Payload { get; init; }
/// <summary>
/// When the request was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the request expires.
/// </summary>
public DateTimeOffset ExpiresAt => CreatedAt.Add(Timeout);
}
/// <summary>
/// Payload for an approval request.
/// </summary>
public sealed record ApprovalPayload
{
/// <summary>
/// Action type being requested.
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Human-readable action label.
/// </summary>
public required string ActionLabel { get; init; }
/// <summary>
/// Action parameters.
/// </summary>
public required ImmutableDictionary<string, string> Parameters { get; init; }
/// <summary>
/// Associated run ID.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Associated finding ID.
/// </summary>
public string? FindingId { get; init; }
/// <summary>
/// Policy reason for requiring approval.
/// </summary>
public string? PolicyReason { get; init; }
}
/// <summary>
/// Current status of an approval request.
/// </summary>
public sealed record ApprovalStatus
{
/// <summary>
/// Request ID.
/// </summary>
public required string RequestId { get; init; }
/// <summary>
/// Current state.
/// </summary>
public required ApprovalState State { get; init; }
/// <summary>
/// Approvals received so far.
/// </summary>
public ImmutableArray<ApprovalEntry> Approvals { get; init; } =
ImmutableArray<ApprovalEntry>.Empty;
/// <summary>
/// When the request was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the state was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// When the request expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// State of an approval request.
/// </summary>
public enum ApprovalState
{
/// <summary>
/// Waiting for approvals.
/// </summary>
Pending,
/// <summary>
/// All required approvals received.
/// </summary>
Approved,
/// <summary>
/// Request was denied.
/// </summary>
Denied,
/// <summary>
/// Request timed out.
/// </summary>
Expired,
/// <summary>
/// Request was cancelled.
/// </summary>
Cancelled
}
/// <summary>
/// An individual approval entry.
/// </summary>
public sealed record ApprovalEntry
{
/// <summary>
/// User who approved/denied.
/// </summary>
public required string ApproverId { get; init; }
/// <summary>
/// Whether they approved.
/// </summary>
public required bool Approved { get; init; }
/// <summary>
/// Comments from the approver.
/// </summary>
public string? Comments { get; init; }
/// <summary>
/// When the decision was made.
/// </summary>
public required DateTimeOffset DecidedAt { get; init; }
}
/// <summary>
/// Result of waiting for approval.
/// </summary>
public sealed record ApprovalResult
{
/// <summary>
/// Whether the action was approved.
/// </summary>
public required bool Approved { get; init; }
/// <summary>
/// Whether the request timed out.
/// </summary>
public bool TimedOut { get; init; }
/// <summary>
/// Whether the request was cancelled.
/// </summary>
public bool Cancelled { get; init; }
/// <summary>
/// User who made the final decision.
/// </summary>
public string? ApproverId { get; init; }
/// <summary>
/// When the decision was made.
/// </summary>
public DateTimeOffset? DecidedAt { get; init; }
/// <summary>
/// Comments from the approver.
/// </summary>
public string? Comments { get; init; }
/// <summary>
/// Reason for denial/cancellation.
/// </summary>
public string? DenialReason { get; init; }
}

View File

@@ -0,0 +1,31 @@
// <copyright file="IGuidGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Abstraction for GUID generation to enable deterministic testing.
/// </summary>
public interface IGuidGenerator
{
/// <summary>
/// Generates a new GUID.
/// </summary>
/// <returns>A new GUID.</returns>
Guid NewGuid();
}
/// <summary>
/// Default implementation using Guid.NewGuid().
/// </summary>
internal sealed class DefaultGuidGenerator : IGuidGenerator
{
/// <summary>
/// Singleton instance.
/// </summary>
public static readonly IGuidGenerator Instance = new DefaultGuidGenerator();
/// <inheritdoc />
public Guid NewGuid() => Guid.NewGuid();
}

View File

@@ -0,0 +1,83 @@
// <copyright file="IIdempotencyHandler.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// Handles idempotency checking for action execution.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
/// </summary>
public interface IIdempotencyHandler
{
/// <summary>
/// Generates a deterministic idempotency key for an action.
/// </summary>
/// <param name="proposal">The action proposal.</param>
/// <param name="context">The action context.</param>
/// <returns>The idempotency key.</returns>
string GenerateKey(ActionProposal proposal, ActionContext context);
/// <summary>
/// Checks if an action was already executed.
/// </summary>
/// <param name="key">The idempotency key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Check result with previous execution if found.</returns>
Task<IdempotencyResult> CheckAsync(
string key,
CancellationToken cancellationToken);
/// <summary>
/// Records an action execution for idempotency.
/// </summary>
/// <param name="key">The idempotency key.</param>
/// <param name="result">The execution result.</param>
/// <param name="context">The action context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RecordExecutionAsync(
string key,
ActionExecutionResult result,
ActionContext context,
CancellationToken cancellationToken);
/// <summary>
/// Removes an idempotency record (for rollback scenarios).
/// </summary>
/// <param name="key">The idempotency key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RemoveAsync(
string key,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of idempotency check.
/// </summary>
public sealed record IdempotencyResult
{
/// <summary>
/// Whether the action was already executed.
/// </summary>
public required bool AlreadyExecuted { get; init; }
/// <summary>
/// Previous execution result if executed.
/// </summary>
public ActionExecutionResult? PreviousResult { get; init; }
/// <summary>
/// When the action was previously executed.
/// </summary>
public DateTimeOffset? ExecutedAt { get; init; }
/// <summary>
/// User who executed the action.
/// </summary>
public string? ExecutedBy { get; init; }
/// <summary>
/// Creates a result indicating no previous execution.
/// </summary>
public static IdempotencyResult NotExecuted => new() { AlreadyExecuted = false };
}

View File

@@ -0,0 +1,213 @@
// <copyright file="IdempotencyHandler.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Actions;
/// <summary>
/// In-memory idempotency handler for development and testing.
/// In production, this would use PostgreSQL with TTL cleanup.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
/// </summary>
internal sealed class IdempotencyHandler : IIdempotencyHandler
{
private readonly ConcurrentDictionary<string, IdempotencyRecord> _records = new();
private readonly TimeProvider _timeProvider;
private readonly IdempotencyOptions _options;
private readonly ILogger<IdempotencyHandler> _logger;
public IdempotencyHandler(
TimeProvider timeProvider,
IOptions<IdempotencyOptions> options,
ILogger<IdempotencyHandler> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? new IdempotencyOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public string GenerateKey(ActionProposal proposal, ActionContext context)
{
ArgumentNullException.ThrowIfNull(proposal);
ArgumentNullException.ThrowIfNull(context);
// Key components: tenant, action type, target identifiers
var sb = new StringBuilder();
sb.Append(context.TenantId);
sb.Append('|');
sb.Append(proposal.ActionType);
// Add target-specific components in sorted order for determinism
var targets = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (proposal.Parameters.TryGetValue("cve_id", out var cveId) && !string.IsNullOrEmpty(cveId))
{
targets["cve"] = cveId;
}
if (proposal.Parameters.TryGetValue("image_digest", out var digest) && !string.IsNullOrEmpty(digest))
{
targets["image"] = digest;
}
if (proposal.Parameters.TryGetValue("finding_id", out var findingId) && !string.IsNullOrEmpty(findingId))
{
targets["finding"] = findingId;
}
if (proposal.Parameters.TryGetValue("component", out var component) && !string.IsNullOrEmpty(component))
{
targets["component"] = component;
}
// If using explicit idempotency key from proposal, include it
if (!string.IsNullOrEmpty(proposal.IdempotencyKey))
{
targets["key"] = proposal.IdempotencyKey;
}
foreach (var (key, value) in targets)
{
sb.Append('|');
sb.Append(key);
sb.Append(':');
sb.Append(value);
}
var content = sb.ToString();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <inheritdoc />
public Task<IdempotencyResult> CheckAsync(
string key,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(key);
if (!_records.TryGetValue(key, out var record))
{
return Task.FromResult(IdempotencyResult.NotExecuted);
}
// Check if record has expired
var now = _timeProvider.GetUtcNow();
if (now >= record.ExpiresAt)
{
_records.TryRemove(key, out _);
return Task.FromResult(IdempotencyResult.NotExecuted);
}
_logger.LogDebug(
"Idempotency hit for key {Key}, previously executed at {ExecutedAt} by {ExecutedBy}",
key, record.ExecutedAt, record.ExecutedBy);
return Task.FromResult(new IdempotencyResult
{
AlreadyExecuted = true,
PreviousResult = record.Result,
ExecutedAt = record.ExecutedAt,
ExecutedBy = record.ExecutedBy
});
}
/// <inheritdoc />
public Task RecordExecutionAsync(
string key,
ActionExecutionResult result,
ActionContext context,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(context);
var now = _timeProvider.GetUtcNow();
var record = new IdempotencyRecord
{
Key = key,
Result = result,
ExecutedAt = now,
ExecutedBy = context.UserId,
TenantId = context.TenantId,
ExpiresAt = now.AddDays(_options.TtlDays)
};
_records[key] = record;
_logger.LogDebug(
"Recorded idempotency for key {Key}, expires at {ExpiresAt}",
key, record.ExpiresAt.ToString("O", CultureInfo.InvariantCulture));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveAsync(
string key,
CancellationToken cancellationToken)
{
_records.TryRemove(key, out _);
_logger.LogDebug("Removed idempotency record for key {Key}", key);
return Task.CompletedTask;
}
/// <summary>
/// Cleans up expired records. Should be called periodically.
/// </summary>
public void CleanupExpired()
{
var now = _timeProvider.GetUtcNow();
var expiredKeys = _records
.Where(kvp => now >= kvp.Value.ExpiresAt)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
_records.TryRemove(key, out _);
}
if (expiredKeys.Count > 0)
{
_logger.LogInformation("Cleaned up {Count} expired idempotency records", expiredKeys.Count);
}
}
private sealed record IdempotencyRecord
{
public required string Key { get; init; }
public required ActionExecutionResult Result { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required string ExecutedBy { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
}
/// <summary>
/// Configuration options for idempotency handling.
/// </summary>
public sealed class IdempotencyOptions
{
/// <summary>
/// Days to retain idempotency records before expiration.
/// </summary>
public int TtlDays { get; set; } = 30;
/// <summary>
/// Whether idempotency checking is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,348 @@
// <copyright file="AttestationIntegration.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Chat;
/// <summary>
/// Integrates AI attestation with the conversation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-005
/// </summary>
public sealed class AttestationIntegration : IAttestationIntegration
{
private readonly IAiAttestationService _attestationService;
private readonly IPromptTemplateRegistry _templateRegistry;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AttestationIntegration> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AttestationIntegration"/> class.
/// </summary>
public AttestationIntegration(
IAiAttestationService attestationService,
IPromptTemplateRegistry templateRegistry,
TimeProvider timeProvider,
ILogger<AttestationIntegration> logger)
{
_attestationService = attestationService ?? throw new ArgumentNullException(nameof(attestationService));
_templateRegistry = templateRegistry ?? throw new ArgumentNullException(nameof(templateRegistry));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<AiAttestationResult?> AttestTurnAsync(
string runId,
string tenantId,
ConversationTurn turn,
GroundingResult? groundingResult,
bool sign,
CancellationToken ct)
{
if (turn.Role != TurnRole.Assistant)
{
// Only attest assistant (AI) turns
return null;
}
_logger.LogDebug("Attesting turn {TurnId} for run {RunId}", turn.TurnId, runId);
try
{
// Extract claims from grounding result and convert to ClaimEvidence format
var claims = groundingResult?.GroundedClaims
.Select(c => new ClaimEvidence
{
Text = c.ClaimText,
Position = c.Position,
Length = c.Length,
GroundingScore = c.Confidence,
GroundedBy = c.EvidenceLinks
.Select(e => e.ToString())
.ToImmutableArray(),
Verified = c.Confidence >= 0.7
})
.ToImmutableArray() ?? ImmutableArray<ClaimEvidence>.Empty;
// Create attestation for the first claim (representative of the turn)
// In practice, you might create multiple claim attestations per turn
var contentDigest = ComputeContentDigest(turn.Content);
var claimText = turn.Content.Length > 500
? turn.Content[..500] + "..."
: turn.Content;
var claimAttestation = new AiClaimAttestation
{
ClaimId = $"{runId}:{turn.TurnId}:turn-claim",
RunId = runId,
TurnId = turn.TurnId,
TenantId = tenantId,
ClaimText = claimText,
ClaimDigest = ComputeContentDigest(claimText),
GroundedBy = claims.SelectMany(c => c.GroundedBy).Distinct().ToImmutableArray(),
GroundingScore = groundingResult?.OverallScore ?? 0.0,
Verified = groundingResult?.OverallScore >= 0.7,
Timestamp = turn.Timestamp,
ContentDigest = contentDigest
};
var result = await _attestationService.CreateClaimAttestationAsync(
claimAttestation,
sign,
ct);
_logger.LogInformation(
"Created claim attestation {AttestationId} for turn {TurnId}",
result.AttestationId, turn.TurnId);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to create claim attestation for turn {TurnId}: {Message}",
turn.TurnId, ex.Message);
return null;
}
}
/// <inheritdoc/>
public async Task<AiAttestationResult?> AttestRunAsync(
Conversation conversation,
string runId,
string promptTemplateName,
AiModelInfo modelInfo,
bool sign,
CancellationToken ct)
{
_logger.LogDebug(
"Attesting run {RunId} for conversation {ConversationId}",
runId, conversation.ConversationId);
try
{
// Get prompt template info
var templateInfo = _templateRegistry.GetTemplateInfo(promptTemplateName);
// Collect all evidence URIs from turns
var allEvidenceUris = conversation.Turns
.SelectMany(t => t.EvidenceLinks)
.Select(e => e.Uri)
.Distinct()
.ToImmutableArray();
// Build turn summaries with claims
var turns = conversation.Turns
.Select(t => new AiTurnSummary
{
TurnId = t.TurnId,
Role = MapRole(t.Role),
ContentDigest = ComputeContentDigest(t.Content),
Timestamp = t.Timestamp,
Claims = t.Role == TurnRole.Assistant
? ImmutableArray.Create(new ClaimEvidence
{
Text = t.Content.Length > 200 ? t.Content[..200] + "..." : t.Content,
Position = 0,
Length = Math.Min(t.Content.Length, 200),
GroundedBy = t.EvidenceLinks.Select(e => e.Uri).ToImmutableArray(),
GroundingScore = 0.8,
Verified = t.EvidenceLinks.Length > 0
})
: ImmutableArray<ClaimEvidence>.Empty
})
.ToImmutableArray();
// Build run context
var context = new AiRunContext
{
FindingId = conversation.Context.ScanId,
CveId = conversation.Context.CurrentCveId,
Component = conversation.Context.CurrentComponent,
ImageDigest = conversation.Context.CurrentImageDigest,
PolicyId = conversation.Context.Policy?.PolicyIds.FirstOrDefault(),
EvidenceUris = allEvidenceUris
};
var runAttestation = new AiRunAttestation
{
RunId = runId,
TenantId = conversation.TenantId,
UserId = conversation.UserId ?? "unknown",
ConversationId = conversation.ConversationId,
StartedAt = conversation.CreatedAt,
CompletedAt = _timeProvider.GetUtcNow(),
Model = modelInfo,
PromptTemplate = templateInfo,
Context = context,
Turns = turns,
OverallGroundingScore = ComputeOverallGroundingScore(turns)
};
var result = await _attestationService.CreateRunAttestationAsync(
runAttestation,
sign,
ct);
_logger.LogInformation(
"Created run attestation {AttestationId} for run {RunId} with {TurnCount} turns",
result.AttestationId, runId, turns.Length);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to create run attestation for {RunId}: {Message}",
runId, ex.Message);
return null;
}
}
/// <inheritdoc/>
public async Task<AiAttestationVerificationResult> VerifyRunAsync(
string runId,
CancellationToken ct)
{
_logger.LogDebug("Verifying run attestation for {RunId}", runId);
var result = await _attestationService.VerifyRunAttestationAsync(runId, ct);
if (result.Valid)
{
_logger.LogInformation("Run {RunId} attestation verified successfully", runId);
}
else
{
_logger.LogWarning(
"Run {RunId} attestation verification failed: {Reason}",
runId, result.FailureReason);
}
return result;
}
private static string ComputeContentDigest(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private static double ComputeOverallGroundingScore(ImmutableArray<AiTurnSummary> turns)
{
var assistantTurns = turns.Where(t => t.Role == Attestation.Models.TurnRole.Assistant).ToList();
if (assistantTurns.Count == 0) return 0.0;
var avgScore = assistantTurns
.Where(t => t.GroundingScore.HasValue)
.DefaultIfEmpty()
.Average(t => t?.GroundingScore ?? 0.0);
return avgScore;
}
private static Attestation.Models.TurnRole MapRole(TurnRole role) => role switch
{
TurnRole.User => Attestation.Models.TurnRole.User,
TurnRole.Assistant => Attestation.Models.TurnRole.Assistant,
TurnRole.System => Attestation.Models.TurnRole.System,
_ => Attestation.Models.TurnRole.System
};
}
/// <summary>
/// Interface for attestation integration.
/// </summary>
public interface IAttestationIntegration
{
/// <summary>
/// Creates an attestation for a conversation turn.
/// </summary>
/// <param name="runId">The run identifier.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="turn">The conversation turn.</param>
/// <param name="groundingResult">The grounding validation result.</param>
/// <param name="sign">Whether to sign the attestation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation result, or null if attestation is skipped.</returns>
Task<AiAttestationResult?> AttestTurnAsync(
string runId,
string tenantId,
ConversationTurn turn,
GroundingResult? groundingResult,
bool sign,
CancellationToken ct);
/// <summary>
/// Creates an attestation for a completed run.
/// </summary>
/// <param name="conversation">The conversation.</param>
/// <param name="runId">The run identifier.</param>
/// <param name="promptTemplateName">The prompt template name used.</param>
/// <param name="modelInfo">The AI model information.</param>
/// <param name="sign">Whether to sign the attestation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation result, or null if attestation fails.</returns>
Task<AiAttestationResult?> AttestRunAsync(
Conversation conversation,
string runId,
string promptTemplateName,
AiModelInfo modelInfo,
bool sign,
CancellationToken ct);
/// <summary>
/// Verifies a run attestation.
/// </summary>
/// <param name="runId">The run identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The verification result.</returns>
Task<AiAttestationVerificationResult> VerifyRunAsync(
string runId,
CancellationToken ct);
}
/// <summary>
/// Result of grounding validation for a turn.
/// </summary>
public sealed record GroundingResult
{
/// <summary>Overall grounding score (0.0-1.0).</summary>
public required double OverallScore { get; init; }
/// <summary>Individual grounded claims.</summary>
public ImmutableArray<GroundedClaim> GroundedClaims { get; init; } = ImmutableArray<GroundedClaim>.Empty;
/// <summary>Ungrounded claims (claims without evidence).</summary>
public ImmutableArray<string> UngroundedClaims { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// A claim that has been grounded to evidence.
/// </summary>
public sealed record GroundedClaim
{
/// <summary>The claim text.</summary>
public required string ClaimText { get; init; }
/// <summary>Position in the content.</summary>
public required int Position { get; init; }
/// <summary>Length of the claim.</summary>
public required int Length { get; init; }
/// <summary>Confidence score (0.0-1.0).</summary>
public required double Confidence { get; init; }
/// <summary>Evidence links supporting this claim.</summary>
public ImmutableArray<Uri> EvidenceLinks { get; init; } = ImmutableArray<Uri>.Empty;
}

View File

@@ -345,16 +345,31 @@ public sealed record ConversationContext
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Gets the conversation topic.
/// </summary>
public string? Topic { get; init; }
/// <summary>
/// Gets the current CVE being discussed.
/// </summary>
public string? CurrentCveId { get; init; }
/// <summary>
/// Gets the focused CVE ID (alias for CurrentCveId).
/// </summary>
public string? FocusedCveId => CurrentCveId;
/// <summary>
/// Gets the current component PURL.
/// </summary>
public string? CurrentComponent { get; init; }
/// <summary>
/// Gets the focused component (alias for CurrentComponent).
/// </summary>
public string? FocusedComponent => CurrentComponent;
/// <summary>
/// Gets the current image digest.
/// </summary>
@@ -370,6 +385,49 @@ public sealed record ConversationContext
/// </summary>
public string? SbomId { get; init; }
/// <summary>
/// Gets the finding ID in context.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
/// </summary>
public string? FindingId { get; init; }
/// <summary>
/// Gets the run ID in context.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Gets the user ID.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
/// </summary>
public string? UserId { get; init; }
/// <summary>
/// Gets the vulnerability severity.
/// </summary>
public string? Severity { get; init; }
/// <summary>
/// Gets whether the vulnerability is reachable.
/// </summary>
public bool? IsReachable { get; init; }
/// <summary>
/// Gets the CVSS score.
/// </summary>
public double? CvssScore { get; init; }
/// <summary>
/// Gets the EPSS score.
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// Gets context tags.
/// </summary>
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets accumulated evidence links.
/// </summary>
@@ -413,11 +471,21 @@ public sealed record ConversationTurn
/// </summary>
public required string TurnId { get; init; }
/// <summary>
/// Gets the turn number in the conversation (1-based).
/// </summary>
public int TurnNumber { get; init; }
/// <summary>
/// Gets the role (user/assistant/system).
/// </summary>
public required TurnRole Role { get; init; }
/// <summary>
/// Gets the actor identifier (user ID or system ID).
/// </summary>
public string? ActorId { get; init; }
/// <summary>
/// Gets the message content.
/// </summary>
@@ -536,11 +604,27 @@ public sealed record ProposedAction
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Gets the action subject (CVE, component, etc.).
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Gets the action rationale.
/// </summary>
public string? Rationale { get; init; }
/// <summary>
/// Gets the action payload (JSON).
/// </summary>
public string? Payload { get; init; }
/// <summary>
/// Gets action parameters.
/// </summary>
public ImmutableDictionary<string, string> Parameters { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Gets whether this action requires confirmation.
/// </summary>

View File

@@ -0,0 +1,345 @@
// <copyright file="EvidencePackChatIntegration.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.AdvisoryAI.Chat;
/// <summary>
/// Integrates Evidence Pack creation with chat grounding validation.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
/// </summary>
public sealed class EvidencePackChatIntegration
{
private readonly IEvidencePackService _evidencePackService;
private readonly ILogger<EvidencePackChatIntegration> _logger;
private readonly EvidencePackChatOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="EvidencePackChatIntegration"/> class.
/// </summary>
public EvidencePackChatIntegration(
IEvidencePackService evidencePackService,
TimeProvider timeProvider,
ILogger<EvidencePackChatIntegration> logger,
IOptions<EvidencePackChatOptions>? options = null)
{
_evidencePackService = evidencePackService ?? throw new ArgumentNullException(nameof(evidencePackService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new EvidencePackChatOptions();
}
/// <summary>
/// Creates an Evidence Pack from a grounding validation result if conditions are met.
/// </summary>
/// <param name="grounding">The grounding validation result.</param>
/// <param name="context">The conversation context.</param>
/// <param name="conversationId">The conversation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created Evidence Pack, or null if conditions not met.</returns>
public async Task<EvidencePack?> TryCreateFromGroundingAsync(
GroundingValidationResult grounding,
ConversationContext context,
string conversationId,
CancellationToken cancellationToken = default)
{
if (!_options.AutoCreateEnabled)
{
_logger.LogDebug("Auto-create Evidence Packs disabled");
return null;
}
if (!grounding.IsAcceptable)
{
_logger.LogDebug("Grounding not acceptable (score {Score:F2}), skipping Evidence Pack creation",
grounding.GroundingScore);
return null;
}
if (grounding.GroundingScore < _options.MinGroundingScore)
{
_logger.LogDebug("Grounding score {Score:F2} below threshold {Threshold:F2}, skipping Evidence Pack creation",
grounding.GroundingScore, _options.MinGroundingScore);
return null;
}
if (grounding.ValidatedLinks.Length == 0)
{
_logger.LogDebug("No validated links in grounding result, skipping Evidence Pack creation");
return null;
}
// Build claims from grounded claims
var claims = BuildClaimsFromGrounding(grounding);
if (claims.Length == 0)
{
_logger.LogDebug("No claims extracted from grounding, skipping Evidence Pack creation");
return null;
}
// Build evidence items from validated links
var evidence = BuildEvidenceFromLinks(grounding.ValidatedLinks);
if (evidence.Length == 0)
{
_logger.LogDebug("No evidence items built from links, skipping Evidence Pack creation");
return null;
}
// Determine subject
var subject = BuildSubject(context);
// Create context
var packContext = new EvidencePackContext
{
TenantId = context.TenantId,
RunId = context.RunId,
ConversationId = conversationId,
UserId = context.UserId,
GeneratedBy = "AdvisoryAI"
};
try
{
var pack = await _evidencePackService.CreateAsync(
claims.ToArray(),
evidence.ToArray(),
subject,
packContext,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created Evidence Pack {PackId} from chat grounding (score {Score:F2}, {ClaimCount} claims, {EvidenceCount} evidence items)",
pack.PackId, grounding.GroundingScore, claims.Length, evidence.Length);
return pack;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create Evidence Pack from grounding result");
return null;
}
}
private ImmutableArray<EvidenceClaim> BuildClaimsFromGrounding(GroundingValidationResult grounding)
{
var claims = new List<EvidenceClaim>();
var claimIndex = 0;
// Build claims from grounded claims (claims near valid links)
var validLinkPositions = grounding.ValidatedLinks
.Where(l => l.IsValid)
.Select(l => l.Position)
.ToHashSet();
// We need to extract claims that were considered grounded
// These are claims that had a nearby link
// For now, we infer from the structure - grounded claims = TotalClaims - UngroundedClaims
// We'll create claims based on the validated links and their context
foreach (var link in grounding.ValidatedLinks.Where(l => l.IsValid))
{
var claimId = $"claim-{claimIndex++:D3}";
var evidenceId = $"ev-{link.Type}-{claimIndex:D3}";
// Determine claim type based on link type
var claimType = link.Type switch
{
"vex" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
"reach" or "runtime" => Evidence.Pack.Models.ClaimType.Reachability,
"sbom" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
_ => Evidence.Pack.Models.ClaimType.Custom
};
// Build claim text based on link context
var claimText = link.Type switch
{
"vex" => $"VEX statement from {link.Path}",
"reach" => $"Reachability analysis for {link.Path}",
"runtime" => $"Runtime observation from {link.Path}",
"sbom" => $"Component present in SBOM {link.Path}",
"ops-mem" => $"OpsMemory context from {link.Path}",
_ => $"Evidence from {link.Type}:{link.Path}"
};
claims.Add(new EvidenceClaim
{
ClaimId = claimId,
Text = claimText,
Type = claimType,
Status = "grounded",
Confidence = grounding.GroundingScore,
EvidenceIds = [evidenceId],
Source = "ai"
});
}
return claims.ToImmutableArray();
}
private ImmutableArray<EvidenceItem> BuildEvidenceFromLinks(ImmutableArray<ValidatedLink> validatedLinks)
{
var evidence = new List<EvidenceItem>();
var evidenceIndex = 0;
foreach (var link in validatedLinks.Where(l => l.IsValid))
{
var evidenceId = $"ev-{link.Type}-{evidenceIndex++:D3}";
var evidenceType = link.Type switch
{
"sbom" => EvidenceType.Sbom,
"vex" => EvidenceType.Vex,
"reach" => EvidenceType.Reachability,
"runtime" => EvidenceType.Runtime,
"attest" => EvidenceType.Attestation,
"ops-mem" => EvidenceType.OpsMemory,
_ => EvidenceType.Custom
};
var snapshot = CreateSnapshotForType(link.Type, link.Path, link.ResolvedUri);
evidence.Add(new EvidenceItem
{
EvidenceId = evidenceId,
Type = evidenceType,
Uri = link.ResolvedUri ?? $"stella://{link.Type}/{link.Path}",
Digest = ComputeLinkDigest(link),
CollectedAt = _timeProvider.GetUtcNow(),
Snapshot = snapshot
});
}
return evidence.ToImmutableArray();
}
private static EvidenceSnapshot CreateSnapshotForType(string type, string path, string? resolvedUri)
{
return type switch
{
"sbom" => EvidenceSnapshot.Custom("sbom", new Dictionary<string, object?>
{
["path"] = path,
["resolvedUri"] = resolvedUri,
["source"] = "chat-grounding"
}.ToImmutableDictionary()),
"vex" => EvidenceSnapshot.Custom("vex", new Dictionary<string, object?>
{
["path"] = path,
["resolvedUri"] = resolvedUri,
["source"] = "chat-grounding"
}.ToImmutableDictionary()),
"reach" => EvidenceSnapshot.Custom("reachability", new Dictionary<string, object?>
{
["path"] = path,
["resolvedUri"] = resolvedUri,
["source"] = "chat-grounding"
}.ToImmutableDictionary()),
"runtime" => EvidenceSnapshot.Custom("runtime", new Dictionary<string, object?>
{
["path"] = path,
["resolvedUri"] = resolvedUri,
["source"] = "chat-grounding"
}.ToImmutableDictionary()),
_ => EvidenceSnapshot.Custom(type, new Dictionary<string, object?>
{
["path"] = path,
["resolvedUri"] = resolvedUri,
["source"] = "chat-grounding"
}.ToImmutableDictionary())
};
}
private static string ComputeLinkDigest(ValidatedLink link)
{
var input = $"{link.Type}:{link.Path}:{link.ResolvedUri}";
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static EvidenceSubject BuildSubject(ConversationContext context)
{
// Determine subject type based on available context
if (!string.IsNullOrEmpty(context.FindingId))
{
return new EvidenceSubject
{
Type = EvidenceSubjectType.Finding,
FindingId = context.FindingId,
CveId = context.CurrentCveId,
Component = context.CurrentComponent,
ImageDigest = context.CurrentImageDigest
};
}
if (!string.IsNullOrEmpty(context.CurrentCveId))
{
return new EvidenceSubject
{
Type = EvidenceSubjectType.Cve,
CveId = context.CurrentCveId,
Component = context.CurrentComponent,
ImageDigest = context.CurrentImageDigest
};
}
if (!string.IsNullOrEmpty(context.CurrentComponent))
{
return new EvidenceSubject
{
Type = EvidenceSubjectType.Component,
Component = context.CurrentComponent,
ImageDigest = context.CurrentImageDigest
};
}
if (!string.IsNullOrEmpty(context.CurrentImageDigest))
{
return new EvidenceSubject
{
Type = EvidenceSubjectType.Image,
ImageDigest = context.CurrentImageDigest
};
}
// Default to custom subject
return new EvidenceSubject
{
Type = EvidenceSubjectType.Custom
};
}
}
/// <summary>
/// Options for Evidence Pack chat integration.
/// </summary>
public sealed class EvidencePackChatOptions
{
/// <summary>
/// Gets or sets whether to auto-create Evidence Packs.
/// Default: true.
/// </summary>
public bool AutoCreateEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the minimum grounding score for auto-creation.
/// Default: 0.7.
/// </summary>
public double MinGroundingScore { get; set; } = 0.7;
/// <summary>
/// Gets or sets whether to auto-sign created packs.
/// Default: false.
/// </summary>
public bool AutoSign { get; set; }
}

View File

@@ -341,7 +341,7 @@ public sealed partial class GroundingValidator
return claim[..(maxLength - 3)] + "...";
}
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs|ops-mem):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
private static partial Regex ObjectLinkRegex();
[GeneratedRegex(@"(?:is|are|was|were|has been|have been)\s+(?:not\s+)?(?:affected|vulnerable|exploitable|fixed|patched|mitigated|under investigation)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]

View File

@@ -0,0 +1,294 @@
// <copyright file="OpsMemoryIntegration.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Integration;
using StellaOps.OpsMemory.Models;
using OpsMemoryConversationContext = StellaOps.OpsMemory.Integration.ConversationContext;
namespace StellaOps.AdvisoryAI.Chat;
/// <summary>
/// Integrates OpsMemory with AdvisoryAI chat sessions.
/// Enables surfacing past decisions and recording new decisions from chat actions.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-004
/// </summary>
public sealed class OpsMemoryIntegration : IOpsMemoryIntegration
{
private readonly IOpsMemoryChatProvider _opsMemoryProvider;
private readonly OpsMemoryContextEnricher _contextEnricher;
private readonly ILogger<OpsMemoryIntegration> _logger;
/// <summary>
/// Creates a new OpsMemoryIntegration.
/// </summary>
public OpsMemoryIntegration(
IOpsMemoryChatProvider opsMemoryProvider,
OpsMemoryContextEnricher contextEnricher,
ILogger<OpsMemoryIntegration> logger)
{
_opsMemoryProvider = opsMemoryProvider ?? throw new ArgumentNullException(nameof(opsMemoryProvider));
_contextEnricher = contextEnricher ?? throw new ArgumentNullException(nameof(contextEnricher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
ConversationContext context,
string tenantId,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Enriching conversation context with OpsMemory for tenant {TenantId}",
tenantId);
// Build chat context request from conversation context
var request = BuildChatContextRequest(context, tenantId);
// Enrich prompt with OpsMemory context
var enrichedPrompt = await _contextEnricher.EnrichPromptAsync(
request,
existingPrompt: null,
cancellationToken).ConfigureAwait(false);
return new OpsMemoryEnrichmentResult
{
SystemPromptAddition = enrichedPrompt.SystemPromptAddition ?? string.Empty,
ContextBlock = enrichedPrompt.EnrichedPrompt,
ReferencedMemoryIds = enrichedPrompt.DecisionsReferenced,
HasEnrichment = enrichedPrompt.HasEnrichment,
SimilarDecisionCount = enrichedPrompt.Context.SimilarDecisions.Length,
ApplicableTacticCount = enrichedPrompt.Context.ApplicableTactics.Length
};
}
/// <inheritdoc />
public async Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
ProposedAction action,
Conversation conversation,
ConversationTurn turn,
CancellationToken cancellationToken)
{
if (!ShouldRecordAction(action))
{
_logger.LogDebug("Skipping non-decision action: {ActionType}", action.ActionType);
return null;
}
_logger.LogInformation(
"Recording decision from chat action: {ActionType} for {Subject}",
action.ActionType, action.Subject ?? "(unknown)");
// Build action execution result
var actionResult = new ActionExecutionResult
{
Action = MapActionType(action.ActionType),
CveId = ExtractCveId(action),
Component = ExtractComponent(action),
Success = true, // Assuming executed action was successful
Rationale = action.Rationale ?? turn.Content,
ExecutedAt = turn.Timestamp,
ActorId = turn.ActorId ?? "system",
Metadata = action.Parameters
};
// Build conversation context for OpsMemory
var opsMemoryConversationContext = new OpsMemoryConversationContext
{
ConversationId = conversation.ConversationId,
TenantId = conversation.TenantId,
UserId = conversation.UserId ?? "unknown",
Topic = conversation.Context.Topic,
TurnNumber = turn.TurnNumber,
Situation = ExtractSituation(conversation.Context),
EvidenceLinks = turn.EvidenceLinks.Select(e => e.Uri).ToImmutableArray()
};
// Record to OpsMemory
var record = await _opsMemoryProvider.RecordFromActionAsync(
actionResult,
opsMemoryConversationContext,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created OpsMemory record {MemoryId} from conversation {ConversationId}",
record.MemoryId, conversation.ConversationId);
return record;
}
/// <inheritdoc />
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
string tenantId,
int limit,
CancellationToken cancellationToken)
{
return await _opsMemoryProvider.GetRecentDecisionsAsync(tenantId, limit, cancellationToken)
.ConfigureAwait(false);
}
private static ChatContextRequest BuildChatContextRequest(
ConversationContext context,
string tenantId)
{
return new ChatContextRequest
{
TenantId = tenantId,
CveId = context.FocusedCveId,
Component = context.FocusedComponent,
Severity = context.Severity,
Reachability = context.IsReachable.HasValue
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
: null,
CvssScore = context.CvssScore,
EpssScore = context.EpssScore,
ContextTags = context.Tags,
MaxSuggestions = 3,
MinSimilarity = 0.6
};
}
private static bool ShouldRecordAction(ProposedAction action)
{
// Only record security decision actions
var recordableActions = new[]
{
"accept_risk", "suppress", "quarantine", "remediate", "defer",
"approve", "reject", "escalate", "mitigate", "monitor"
};
return recordableActions.Contains(action.ActionType, StringComparer.OrdinalIgnoreCase);
}
private static DecisionAction MapActionType(string actionType)
{
// Map action types to OpsMemory.Models.DecisionAction enum values
return actionType.ToUpperInvariant() switch
{
"ACCEPT_RISK" or "ACCEPT" or "SUPPRESS" or "APPROVE" => DecisionAction.Accept,
"QUARANTINE" => DecisionAction.Quarantine,
"REMEDIATE" or "FIX" => DecisionAction.Remediate,
"DEFER" or "POSTPONE" or "MONITOR" => DecisionAction.Defer,
"REJECT" or "FALSE_POSITIVE" => DecisionAction.FalsePositive,
"ESCALATE" => DecisionAction.Escalate,
"MITIGATE" => DecisionAction.Mitigate,
_ => DecisionAction.Defer // Default to Defer for unknown actions
};
}
private static string? ExtractCveId(ProposedAction action)
{
if (action.Parameters.TryGetValue("cve_id", out var cveId))
return cveId;
if (action.Parameters.TryGetValue("cveId", out cveId))
return cveId;
if (action.Subject?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
return action.Subject;
return null;
}
private static string? ExtractComponent(ProposedAction action)
{
if (action.Parameters.TryGetValue("component", out var component))
return component;
if (action.Parameters.TryGetValue("purl", out var purl))
return purl;
return null;
}
private static SituationContext? ExtractSituation(ConversationContext context)
{
if (string.IsNullOrWhiteSpace(context.FocusedCveId) &&
string.IsNullOrWhiteSpace(context.FocusedComponent))
{
return null;
}
return new SituationContext
{
CveId = context.FocusedCveId,
Component = context.FocusedComponent,
Severity = context.Severity,
Reachability = context.IsReachable.HasValue
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
: ReachabilityStatus.Unknown,
CvssScore = context.CvssScore,
EpssScore = context.EpssScore,
ContextTags = context.Tags
};
}
}
/// <summary>
/// Interface for OpsMemory integration with AdvisoryAI.
/// </summary>
public interface IOpsMemoryIntegration
{
/// <summary>
/// Enriches conversation context with OpsMemory data.
/// </summary>
Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
ConversationContext context,
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Records a decision from an executed chat action to OpsMemory.
/// </summary>
Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
ProposedAction action,
Conversation conversation,
ConversationTurn turn,
CancellationToken cancellationToken);
/// <summary>
/// Gets recent decisions for context.
/// </summary>
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
string tenantId,
int limit,
CancellationToken cancellationToken);
}
/// <summary>
/// Result of OpsMemory enrichment for conversation.
/// </summary>
public sealed record OpsMemoryEnrichmentResult
{
/// <summary>
/// Gets the system prompt addition.
/// </summary>
public required string SystemPromptAddition { get; init; }
/// <summary>
/// Gets the context block to include in the conversation.
/// </summary>
public required string ContextBlock { get; init; }
/// <summary>
/// Gets the memory IDs referenced.
/// </summary>
public ImmutableArray<string> ReferencedMemoryIds { get; init; } = [];
/// <summary>
/// Gets whether any enrichment was added.
/// </summary>
public bool HasEnrichment { get; init; }
/// <summary>
/// Gets the number of similar decisions found.
/// </summary>
public int SimilarDecisionCount { get; init; }
/// <summary>
/// Gets the number of applicable tactics found.
/// </summary>
public int ApplicableTacticCount { get; init; }
}

View File

@@ -0,0 +1,141 @@
// <copyright file="OpsMemoryLinkResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Storage;
namespace StellaOps.AdvisoryAI.Chat;
/// <summary>
/// Resolves ops-mem:// object links to OpsMemory records.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
/// </summary>
public sealed class OpsMemoryLinkResolver : ITypedLinkResolver
{
private readonly IOpsMemoryStore _store;
private readonly ILogger<OpsMemoryLinkResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OpsMemoryLinkResolver"/> class.
/// </summary>
public OpsMemoryLinkResolver(
IOpsMemoryStore store,
ILogger<OpsMemoryLinkResolver> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Gets the link type this resolver handles.
/// </summary>
public string LinkType => "ops-mem";
/// <inheritdoc />
public async Task<LinkResolution> ResolveAsync(
string path,
string? tenantId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
_logger.LogWarning("Cannot resolve ops-mem link without tenant ID");
return new LinkResolution { Exists = false };
}
try
{
var record = await _store.GetByIdAsync(path, tenantId, cancellationToken)
.ConfigureAwait(false);
if (record is null)
{
_logger.LogDebug("OpsMemory record not found: {MemoryId}", path);
return new LinkResolution { Exists = false };
}
return new LinkResolution
{
Exists = true,
Uri = $"ops-mem://{path}",
ObjectType = "decision",
Metadata = new Dictionary<string, string>
{
["cveId"] = record.Situation?.CveId ?? string.Empty,
["action"] = record.Decision?.Action.ToString() ?? string.Empty,
["outcome"] = record.Outcome?.Status.ToString() ?? "pending",
["decidedAt"] = record.RecordedAt.ToString("O")
}.ToImmutableDictionary()
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resolving ops-mem link: {Path}", path);
return new LinkResolution { Exists = false };
}
}
}
/// <summary>
/// Interface for type-specific link resolvers.
/// </summary>
public interface ITypedLinkResolver
{
/// <summary>
/// Gets the link type this resolver handles.
/// </summary>
string LinkType { get; }
/// <summary>
/// Resolves a link of this type.
/// </summary>
Task<LinkResolution> ResolveAsync(
string path,
string? tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Composite link resolver that delegates to type-specific resolvers.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
/// </summary>
public sealed class CompositeObjectLinkResolver : IObjectLinkResolver
{
private readonly IReadOnlyDictionary<string, ITypedLinkResolver> _resolvers;
private readonly ILogger<CompositeObjectLinkResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CompositeObjectLinkResolver"/> class.
/// </summary>
public CompositeObjectLinkResolver(
IEnumerable<ITypedLinkResolver> resolvers,
ILogger<CompositeObjectLinkResolver> logger)
{
ArgumentNullException.ThrowIfNull(resolvers);
_resolvers = resolvers.ToDictionary(
r => r.LinkType,
r => r,
StringComparer.OrdinalIgnoreCase);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<LinkResolution> ResolveAsync(
string type,
string path,
string? tenantId,
CancellationToken cancellationToken)
{
if (!_resolvers.TryGetValue(type, out var resolver))
{
_logger.LogDebug("No resolver registered for link type: {Type}", type);
return new LinkResolution { Exists = false };
}
return await resolver.ResolveAsync(path, tenantId, cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,86 @@
// <copyright file="ActionsServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.AdvisoryAI.Actions;
namespace StellaOps.AdvisoryAI.DependencyInjection;
/// <summary>
/// Extension methods for registering action-related services.
/// Sprint: SPRINT_20260109_011_004_BE
/// </summary>
public static class ActionsServiceCollectionExtensions
{
/// <summary>
/// Adds action policy integration services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configurePolicy">Optional policy configuration.</param>
/// <param name="configureIdempotency">Optional idempotency configuration.</param>
/// <param name="configureAudit">Optional audit configuration.</param>
/// <param name="configureExecutor">Optional executor configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddActionPolicyIntegration(
this IServiceCollection services,
Action<ActionPolicyOptions>? configurePolicy = null,
Action<IdempotencyOptions>? configureIdempotency = null,
Action<AuditLedgerOptions>? configureAudit = null,
Action<ActionExecutorOptions>? configureExecutor = null)
{
// Configure options
if (configurePolicy is not null)
{
services.Configure(configurePolicy);
}
if (configureIdempotency is not null)
{
services.Configure(configureIdempotency);
}
if (configureAudit is not null)
{
services.Configure(configureAudit);
}
if (configureExecutor is not null)
{
services.Configure(configureExecutor);
}
// Register core services
services.TryAddSingleton<IActionRegistry, ActionRegistry>();
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
// Register policy gate
services.TryAddScoped<IActionPolicyGate, ActionPolicyGate>();
// Register idempotency handler
services.TryAddSingleton<IIdempotencyHandler, IdempotencyHandler>();
// Register approval workflow adapter
services.TryAddSingleton<IApprovalWorkflowAdapter, ApprovalWorkflowAdapter>();
// Register audit ledger
services.TryAddSingleton<IActionAuditLedger, ActionAuditLedger>();
// Register action executor
services.TryAddScoped<IActionExecutor, ActionExecutor>();
return services;
}
/// <summary>
/// Adds action policy integration with default configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDefaultActionPolicyIntegration(
this IServiceCollection services)
{
return services.AddActionPolicyIntegration();
}
}

View File

@@ -0,0 +1,429 @@
// <copyright file="IRunService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// Service for managing AI investigation runs.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-002
/// </summary>
public interface IRunService
{
/// <summary>
/// Creates a new run.
/// </summary>
/// <param name="request">The create request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created run.</returns>
Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a run by ID.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The run, or null if not found.</returns>
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
/// <summary>
/// Queries runs.
/// </summary>
/// <param name="query">The query parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The matching runs.</returns>
Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default);
/// <summary>
/// Adds an event to a run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="eventRequest">The event to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The added event.</returns>
Task<RunEvent> AddEventAsync(
string tenantId,
string runId,
AddRunEventRequest eventRequest,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a user turn to the run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="message">The user message.</param>
/// <param name="userId">The user ID.</param>
/// <param name="evidenceLinks">Optional evidence links.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The added event.</returns>
Task<RunEvent> AddUserTurnAsync(
string tenantId,
string runId,
string message,
string userId,
ImmutableArray<EvidenceLink>? evidenceLinks = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds an assistant turn to the run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="message">The assistant message.</param>
/// <param name="evidenceLinks">Optional evidence links.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The added event.</returns>
Task<RunEvent> AddAssistantTurnAsync(
string tenantId,
string runId,
string message,
ImmutableArray<EvidenceLink>? evidenceLinks = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Proposes an action in the run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="action">The proposed action.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The action proposed event.</returns>
Task<RunEvent> ProposeActionAsync(
string tenantId,
string runId,
ProposeActionRequest action,
CancellationToken cancellationToken = default);
/// <summary>
/// Requests approval for pending actions.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="approvers">The approver user IDs.</param>
/// <param name="reason">The reason for approval request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated run.</returns>
Task<Run> RequestApprovalAsync(
string tenantId,
string runId,
ImmutableArray<string> approvers,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Approves or rejects a run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="approved">Whether to approve or reject.</param>
/// <param name="approverId">The approver's user ID.</param>
/// <param name="reason">The approval/rejection reason.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated run.</returns>
Task<Run> ApproveAsync(
string tenantId,
string runId,
bool approved,
string approverId,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes an approved action.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="actionEventId">The action event ID to execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The action executed event.</returns>
Task<RunEvent> ExecuteActionAsync(
string tenantId,
string runId,
string actionEventId,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds an artifact to the run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="artifact">The artifact to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated run.</returns>
Task<Run> AddArtifactAsync(
string tenantId,
string runId,
RunArtifact artifact,
CancellationToken cancellationToken = default);
/// <summary>
/// Attaches an evidence pack to the run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="evidencePack">The evidence pack reference.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated run.</returns>
Task<Run> AttachEvidencePackAsync(
string tenantId,
string runId,
EvidencePackReference evidencePack,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="summary">Optional completion summary.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The completed run.</returns>
Task<Run> CompleteAsync(
string tenantId,
string runId,
string? summary = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="reason">The cancellation reason.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cancelled run.</returns>
Task<Run> CancelAsync(
string tenantId,
string runId,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Hands off a run to another user.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="toUserId">The user to hand off to.</param>
/// <param name="message">Optional handoff message.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated run.</returns>
Task<Run> HandOffAsync(
string tenantId,
string runId,
string toUserId,
string? message = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates an attestation for a completed run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The attested run.</returns>
Task<Run> AttestAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the timeline for a run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="skip">Number of events to skip.</param>
/// <param name="take">Number of events to take.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The timeline events.</returns>
Task<ImmutableArray<RunEvent>> GetTimelineAsync(
string tenantId,
string runId,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to create a new run.
/// </summary>
public sealed record CreateRunRequest
{
/// <summary>
/// Gets the tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the initiating user ID.
/// </summary>
public required string InitiatedBy { get; init; }
/// <summary>
/// Gets the run title.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Gets the run objective.
/// </summary>
public string? Objective { get; init; }
/// <summary>
/// Gets the initial context.
/// </summary>
public RunContext? Context { get; init; }
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to add a run event.
/// </summary>
public sealed record AddRunEventRequest
{
/// <summary>
/// Gets the event type.
/// </summary>
public required RunEventType Type { get; init; }
/// <summary>
/// Gets the actor ID.
/// </summary>
public string? ActorId { get; init; }
/// <summary>
/// Gets the event content.
/// </summary>
public RunEventContent? Content { get; init; }
/// <summary>
/// Gets evidence links.
/// </summary>
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
/// <summary>
/// Gets the parent event ID.
/// </summary>
public string? ParentEventId { get; init; }
/// <summary>
/// Gets event metadata.
/// </summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to propose an action.
/// </summary>
public sealed record ProposeActionRequest
{
/// <summary>
/// Gets the action type.
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Gets the action subject.
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Gets the rationale.
/// </summary>
public string? Rationale { get; init; }
/// <summary>
/// Gets whether approval is required.
/// </summary>
public bool RequiresApproval { get; init; } = true;
/// <summary>
/// Gets action parameters.
/// </summary>
public ImmutableDictionary<string, string>? Parameters { get; init; }
/// <summary>
/// Gets evidence links.
/// </summary>
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
}
/// <summary>
/// Query parameters for runs.
/// </summary>
public sealed record RunQuery
{
/// <summary>
/// Gets the tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets optional status filter.
/// </summary>
public ImmutableArray<RunStatus>? Statuses { get; init; }
/// <summary>
/// Gets optional initiator filter.
/// </summary>
public string? InitiatedBy { get; init; }
/// <summary>
/// Gets optional CVE filter.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Gets optional component filter.
/// </summary>
public string? Component { get; init; }
/// <summary>
/// Gets optional created after filter.
/// </summary>
public DateTimeOffset? CreatedAfter { get; init; }
/// <summary>
/// Gets optional created before filter.
/// </summary>
public DateTimeOffset? CreatedBefore { get; init; }
/// <summary>
/// Gets the number to skip.
/// </summary>
public int Skip { get; init; }
/// <summary>
/// Gets the number to take.
/// </summary>
public int Take { get; init; } = 20;
}
/// <summary>
/// Result of a run query.
/// </summary>
public sealed record RunQueryResult
{
/// <summary>
/// Gets the matching runs.
/// </summary>
public required ImmutableArray<Run> Runs { get; init; }
/// <summary>
/// Gets the total count.
/// </summary>
public required int TotalCount { get; init; }
/// <summary>
/// Gets whether there are more results.
/// </summary>
public bool HasMore { get; init; }
}

View File

@@ -0,0 +1,104 @@
// <copyright file="IRunStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// Store for persisting runs.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-004
/// </summary>
public interface IRunStore
{
/// <summary>
/// Saves a run.
/// </summary>
/// <param name="run">The run to save.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task SaveAsync(Run run, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a run by ID.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The run, or null if not found.</returns>
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
/// <summary>
/// Queries runs.
/// </summary>
/// <param name="query">The query parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The matching runs and total count.</returns>
Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
RunQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a run.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets runs by status.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="statuses">The statuses to filter by.</param>
/// <param name="skip">Number to skip.</param>
/// <param name="take">Number to take.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The matching runs.</returns>
Task<ImmutableArray<Run>> GetByStatusAsync(
string tenantId,
ImmutableArray<RunStatus> statuses,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets active runs for a user.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="userId">The user ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The user's active runs.</returns>
Task<ImmutableArray<Run>> GetActiveForUserAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets runs pending approval.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="approverId">Optional approver filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Runs pending approval.</returns>
Task<ImmutableArray<Run>> GetPendingApprovalAsync(
string tenantId,
string? approverId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates run status.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="runId">The run ID.</param>
/// <param name="newStatus">The new status.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if updated, false if not found.</returns>
Task<bool> UpdateStatusAsync(
string tenantId,
string runId,
RunStatus newStatus,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,161 @@
// <copyright file="InMemoryRunStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// In-memory implementation of <see cref="IRunStore"/> for development/testing.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-005
/// </summary>
public sealed class InMemoryRunStore : IRunStore
{
private readonly ConcurrentDictionary<(string TenantId, string RunId), Run> _runs = new();
/// <inheritdoc />
public Task SaveAsync(Run run, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(run);
cancellationToken.ThrowIfCancellationRequested();
_runs[(run.TenantId, run.RunId)] = run;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_runs.TryGetValue((tenantId, runId), out var run);
return Task.FromResult(run);
}
/// <inheritdoc />
public Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
RunQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
cancellationToken.ThrowIfCancellationRequested();
var runs = _runs.Values
.Where(r => r.TenantId == query.TenantId)
.Where(r => query.Statuses is null || query.Statuses.Value.Contains(r.Status))
.Where(r => query.InitiatedBy is null || r.InitiatedBy == query.InitiatedBy)
.Where(r => query.CveId is null || r.Context.FocusedCveId == query.CveId)
.Where(r => query.Component is null || r.Context.FocusedComponent == query.Component)
.Where(r => query.CreatedAfter is null || r.CreatedAt >= query.CreatedAfter)
.Where(r => query.CreatedBefore is null || r.CreatedAt <= query.CreatedBefore)
.OrderByDescending(r => r.CreatedAt)
.ToList();
var totalCount = runs.Count;
var pagedRuns = runs
.Skip(query.Skip)
.Take(query.Take)
.ToImmutableArray();
return Task.FromResult((pagedRuns, totalCount));
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_runs.TryRemove((tenantId, runId), out _));
}
/// <inheritdoc />
public Task<ImmutableArray<Run>> GetByStatusAsync(
string tenantId,
ImmutableArray<RunStatus> statuses,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var runs = _runs.Values
.Where(r => r.TenantId == tenantId && statuses.Contains(r.Status))
.OrderByDescending(r => r.CreatedAt)
.Skip(skip)
.Take(take)
.ToImmutableArray();
return Task.FromResult(runs);
}
/// <inheritdoc />
public Task<ImmutableArray<Run>> GetActiveForUserAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var activeStatuses = new[] { RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval };
var runs = _runs.Values
.Where(r => r.TenantId == tenantId)
.Where(r => r.InitiatedBy == userId || r.Metadata.GetValueOrDefault("current_owner") == userId)
.Where(r => activeStatuses.Contains(r.Status))
.OrderByDescending(r => r.UpdatedAt)
.ToImmutableArray();
return Task.FromResult(runs);
}
/// <inheritdoc />
public Task<ImmutableArray<Run>> GetPendingApprovalAsync(
string tenantId,
string? approverId = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var runs = _runs.Values
.Where(r => r.TenantId == tenantId && r.Status == RunStatus.PendingApproval)
.Where(r => approverId is null || (r.Approval?.Approvers.Contains(approverId) ?? false))
.OrderByDescending(r => r.UpdatedAt)
.ToImmutableArray();
return Task.FromResult(runs);
}
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
string tenantId,
string runId,
RunStatus newStatus,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_runs.TryGetValue((tenantId, runId), out var run))
{
return Task.FromResult(false);
}
var updated = run with { Status = newStatus };
_runs[(tenantId, runId)] = updated;
return Task.FromResult(true);
}
/// <summary>
/// Clears all runs (for testing).
/// </summary>
public void Clear()
{
_runs.Clear();
}
/// <summary>
/// Gets the count of runs (for testing).
/// </summary>
public int Count => _runs.Count;
}

View File

@@ -0,0 +1,278 @@
// <copyright file="Run.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// An auditable container for an AI-assisted investigation session.
/// Captures the complete lifecycle from initial query through tool calls,
/// artifact generation, and approvals.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
/// </summary>
public sealed record Run
{
/// <summary>
/// Gets the unique run identifier.
/// Format: run-{timestamp}-{random}
/// </summary>
public required string RunId { get; init; }
/// <summary>
/// Gets the tenant ID for multi-tenancy isolation.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the user who initiated the run.
/// </summary>
public required string InitiatedBy { get; init; }
/// <summary>
/// Gets the run title (user-provided or auto-generated).
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Gets the run objective/goal.
/// </summary>
public string? Objective { get; init; }
/// <summary>
/// Gets the current run status.
/// </summary>
public RunStatus Status { get; init; } = RunStatus.Created;
/// <summary>
/// Gets when the run was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets when the run was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Gets when the run was completed (if completed).
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Gets the ordered timeline of events in this run.
/// </summary>
public ImmutableArray<RunEvent> Events { get; init; } = [];
/// <summary>
/// Gets the artifacts produced by this run.
/// </summary>
public ImmutableArray<RunArtifact> Artifacts { get; init; } = [];
/// <summary>
/// Gets the evidence packs attached to this run.
/// </summary>
public ImmutableArray<EvidencePackReference> EvidencePacks { get; init; } = [];
/// <summary>
/// Gets the run context (CVE focus, component scope, etc.).
/// </summary>
public RunContext Context { get; init; } = new();
/// <summary>
/// Gets the approval requirements and status.
/// </summary>
public ApprovalInfo? Approval { get; init; }
/// <summary>
/// Gets the content hash of the run for attestation.
/// </summary>
public string? ContentDigest { get; init; }
/// <summary>
/// Gets the attestation for this run (if attested).
/// </summary>
public RunAttestation? Attestation { get; init; }
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Status of a run in its lifecycle.
/// </summary>
public enum RunStatus
{
/// <summary>
/// Run has been created but not started.
/// </summary>
Created = 0,
/// <summary>
/// Run is actively in progress.
/// </summary>
Active = 1,
/// <summary>
/// Run is waiting for approval.
/// </summary>
PendingApproval = 2,
/// <summary>
/// Run was approved and actions executed.
/// </summary>
Approved = 3,
/// <summary>
/// Run was rejected.
/// </summary>
Rejected = 4,
/// <summary>
/// Run completed successfully.
/// </summary>
Completed = 5,
/// <summary>
/// Run was cancelled.
/// </summary>
Cancelled = 6,
/// <summary>
/// Run failed with error.
/// </summary>
Failed = 7,
/// <summary>
/// Run expired without completion.
/// </summary>
Expired = 8
}
/// <summary>
/// Context information for a run.
/// </summary>
public sealed record RunContext
{
/// <summary>
/// Gets the focused CVE ID (if any).
/// </summary>
public string? FocusedCveId { get; init; }
/// <summary>
/// Gets the focused component PURL (if any).
/// </summary>
public string? FocusedComponent { get; init; }
/// <summary>
/// Gets the SBOM digest (if any).
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Gets the container image reference (if any).
/// </summary>
public string? ImageReference { get; init; }
/// <summary>
/// Gets the scope tags.
/// </summary>
public ImmutableArray<string> Tags { get; init; } = [];
/// <summary>
/// Gets OpsMemory context if enriched.
/// </summary>
public OpsMemoryRunContext? OpsMemory { get; init; }
}
/// <summary>
/// OpsMemory context attached to a run.
/// </summary>
public sealed record OpsMemoryRunContext
{
/// <summary>
/// Gets the similar past decisions surfaced.
/// </summary>
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
/// <summary>
/// Gets the applicable tactics.
/// </summary>
public ImmutableArray<string> TacticIds { get; init; } = [];
/// <summary>
/// Gets whether OpsMemory enrichment was applied.
/// </summary>
public bool IsEnriched { get; init; }
}
/// <summary>
/// Approval information for a run.
/// </summary>
public sealed record ApprovalInfo
{
/// <summary>
/// Gets whether approval is required.
/// </summary>
public bool Required { get; init; }
/// <summary>
/// Gets the approver user IDs.
/// </summary>
public ImmutableArray<string> Approvers { get; init; } = [];
/// <summary>
/// Gets whether approval was granted.
/// </summary>
public bool? Approved { get; init; }
/// <summary>
/// Gets who approved/rejected.
/// </summary>
public string? ApprovedBy { get; init; }
/// <summary>
/// Gets when approval was decided.
/// </summary>
public DateTimeOffset? ApprovedAt { get; init; }
/// <summary>
/// Gets the approval/rejection reason.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Attestation for a completed run.
/// </summary>
public sealed record RunAttestation
{
/// <summary>
/// Gets the attestation ID.
/// </summary>
public required string AttestationId { get; init; }
/// <summary>
/// Gets the content digest that was attested.
/// </summary>
public required string ContentDigest { get; init; }
/// <summary>
/// Gets the attestation statement URI.
/// </summary>
public required string StatementUri { get; init; }
/// <summary>
/// Gets the signature.
/// </summary>
public required string Signature { get; init; }
/// <summary>
/// Gets when the attestation was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,182 @@
// <copyright file="RunArtifact.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// An artifact produced by a run.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
/// </summary>
public sealed record RunArtifact
{
/// <summary>
/// Gets the artifact ID.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Gets the artifact type.
/// </summary>
public required ArtifactType Type { get; init; }
/// <summary>
/// Gets the artifact name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the artifact description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Gets when the artifact was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the content digest (SHA256).
/// </summary>
public required string ContentDigest { get; init; }
/// <summary>
/// Gets the content size in bytes.
/// </summary>
public long ContentSize { get; init; }
/// <summary>
/// Gets the media type.
/// </summary>
public required string MediaType { get; init; }
/// <summary>
/// Gets the storage URI.
/// </summary>
public string? StorageUri { get; init; }
/// <summary>
/// Gets whether the artifact is inline (small enough to embed).
/// </summary>
public bool IsInline { get; init; }
/// <summary>
/// Gets the inline content (if IsInline).
/// </summary>
public string? InlineContent { get; init; }
/// <summary>
/// Gets the event ID that produced this artifact.
/// </summary>
public string? ProducingEventId { get; init; }
/// <summary>
/// Gets artifact metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Type of artifact produced by a run.
/// </summary>
public enum ArtifactType
{
/// <summary>
/// Evidence pack bundle.
/// </summary>
EvidencePack = 0,
/// <summary>
/// VEX statement.
/// </summary>
VexStatement = 1,
/// <summary>
/// Decision record.
/// </summary>
DecisionRecord = 2,
/// <summary>
/// Action result.
/// </summary>
ActionResult = 3,
/// <summary>
/// Policy evaluation result.
/// </summary>
PolicyResult = 4,
/// <summary>
/// Remediation plan.
/// </summary>
RemediationPlan = 5,
/// <summary>
/// Report document.
/// </summary>
Report = 6,
/// <summary>
/// SBOM document.
/// </summary>
Sbom = 7,
/// <summary>
/// Attestation statement.
/// </summary>
Attestation = 8,
/// <summary>
/// Query result data.
/// </summary>
QueryResult = 9,
/// <summary>
/// Code snippet.
/// </summary>
CodeSnippet = 10,
/// <summary>
/// Configuration file.
/// </summary>
Configuration = 11,
/// <summary>
/// Other artifact type.
/// </summary>
Other = 99
}
/// <summary>
/// Reference to an evidence pack.
/// </summary>
public sealed record EvidencePackReference
{
/// <summary>
/// Gets the evidence pack ID.
/// </summary>
public required string PackId { get; init; }
/// <summary>
/// Gets the pack digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Gets when the pack was attached.
/// </summary>
public required DateTimeOffset AttachedAt { get; init; }
/// <summary>
/// Gets the pack type.
/// </summary>
public string? PackType { get; init; }
/// <summary>
/// Gets the storage URI.
/// </summary>
public string? StorageUri { get; init; }
}

View File

@@ -0,0 +1,428 @@
// <copyright file="RunEvent.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// An event in a run's timeline.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
/// </summary>
public sealed record RunEvent
{
/// <summary>
/// Gets the event ID (unique within the run).
/// </summary>
public required string EventId { get; init; }
/// <summary>
/// Gets the event type.
/// </summary>
public required RunEventType Type { get; init; }
/// <summary>
/// Gets when the event occurred.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Gets the actor who triggered the event (user or system).
/// </summary>
public string? ActorId { get; init; }
/// <summary>
/// Gets the event content (varies by type).
/// </summary>
public RunEventContent? Content { get; init; }
/// <summary>
/// Gets evidence links attached to this event.
/// </summary>
public ImmutableArray<EvidenceLink> EvidenceLinks { get; init; } = [];
/// <summary>
/// Gets the sequence number in the run timeline.
/// </summary>
public int SequenceNumber { get; init; }
/// <summary>
/// Gets the parent event ID (for threaded responses).
/// </summary>
public string? ParentEventId { get; init; }
/// <summary>
/// Gets event metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Type of run event.
/// </summary>
public enum RunEventType
{
/// <summary>
/// Run was created.
/// </summary>
Created = 0,
/// <summary>
/// User message/turn.
/// </summary>
UserTurn = 1,
/// <summary>
/// Assistant response/turn.
/// </summary>
AssistantTurn = 2,
/// <summary>
/// System message.
/// </summary>
SystemMessage = 3,
/// <summary>
/// Tool was called.
/// </summary>
ToolCall = 4,
/// <summary>
/// Tool returned result.
/// </summary>
ToolResult = 5,
/// <summary>
/// Action was proposed.
/// </summary>
ActionProposed = 6,
/// <summary>
/// Approval was requested.
/// </summary>
ApprovalRequested = 7,
/// <summary>
/// Approval was granted.
/// </summary>
ApprovalGranted = 8,
/// <summary>
/// Approval was denied.
/// </summary>
ApprovalDenied = 9,
/// <summary>
/// Action was executed.
/// </summary>
ActionExecuted = 10,
/// <summary>
/// Artifact was produced.
/// </summary>
ArtifactProduced = 11,
/// <summary>
/// Evidence was attached.
/// </summary>
EvidenceAttached = 12,
/// <summary>
/// Run was handed off to another user.
/// </summary>
HandedOff = 13,
/// <summary>
/// Run status changed.
/// </summary>
StatusChanged = 14,
/// <summary>
/// OpsMemory context was enriched.
/// </summary>
OpsMemoryEnriched = 15,
/// <summary>
/// Error occurred.
/// </summary>
Error = 16,
/// <summary>
/// Run was completed.
/// </summary>
Completed = 17,
/// <summary>
/// Run was cancelled.
/// </summary>
Cancelled = 18
}
/// <summary>
/// Content of a run event (polymorphic).
/// </summary>
public abstract record RunEventContent;
/// <summary>
/// Content for user/assistant turn events.
/// </summary>
public sealed record TurnContent : RunEventContent
{
/// <summary>
/// Gets the message text.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets the role (user/assistant/system).
/// </summary>
public required string Role { get; init; }
/// <summary>
/// Gets referenced artifacts.
/// </summary>
public ImmutableArray<string> ArtifactIds { get; init; } = [];
}
/// <summary>
/// Content for tool call events.
/// </summary>
public sealed record ToolCallContent : RunEventContent
{
/// <summary>
/// Gets the tool name.
/// </summary>
public required string ToolName { get; init; }
/// <summary>
/// Gets the tool input parameters.
/// </summary>
public ImmutableDictionary<string, string> Parameters { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Gets whether the call succeeded.
/// </summary>
public bool? Success { get; init; }
/// <summary>
/// Gets the call duration.
/// </summary>
public TimeSpan? Duration { get; init; }
}
/// <summary>
/// Content for tool result events.
/// </summary>
public sealed record ToolResultContent : RunEventContent
{
/// <summary>
/// Gets the tool name.
/// </summary>
public required string ToolName { get; init; }
/// <summary>
/// Gets the result summary.
/// </summary>
public string? ResultSummary { get; init; }
/// <summary>
/// Gets whether the tool succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets the error message (if failed).
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Gets the result artifact ID (if any).
/// </summary>
public string? ArtifactId { get; init; }
}
/// <summary>
/// Content for action proposed events.
/// </summary>
public sealed record ActionProposedContent : RunEventContent
{
/// <summary>
/// Gets the action type.
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Gets the action subject (CVE, component, etc.).
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Gets the proposed action rationale.
/// </summary>
public string? Rationale { get; init; }
/// <summary>
/// Gets whether approval is required.
/// </summary>
public bool RequiresApproval { get; init; }
/// <summary>
/// Gets the action parameters.
/// </summary>
public ImmutableDictionary<string, string> Parameters { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Content for action executed events.
/// </summary>
public sealed record ActionExecutedContent : RunEventContent
{
/// <summary>
/// Gets the action type.
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Gets whether the action succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets the result summary.
/// </summary>
public string? ResultSummary { get; init; }
/// <summary>
/// Gets the OpsMemory record ID (if recorded).
/// </summary>
public string? OpsMemoryRecordId { get; init; }
/// <summary>
/// Gets the produced artifact IDs.
/// </summary>
public ImmutableArray<string> ArtifactIds { get; init; } = [];
}
/// <summary>
/// Content for artifact produced events.
/// </summary>
public sealed record ArtifactProducedContent : RunEventContent
{
/// <summary>
/// Gets the artifact ID.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Gets the artifact type.
/// </summary>
public required string ArtifactType { get; init; }
/// <summary>
/// Gets the artifact name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets the content digest.
/// </summary>
public string? ContentDigest { get; init; }
}
/// <summary>
/// Content for status changed events.
/// </summary>
public sealed record StatusChangedContent : RunEventContent
{
/// <summary>
/// Gets the previous status.
/// </summary>
public required RunStatus FromStatus { get; init; }
/// <summary>
/// Gets the new status.
/// </summary>
public required RunStatus ToStatus { get; init; }
/// <summary>
/// Gets the reason for the change.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Content for OpsMemory enrichment events.
/// </summary>
public sealed record OpsMemoryEnrichedContent : RunEventContent
{
/// <summary>
/// Gets the similar decision IDs surfaced.
/// </summary>
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
/// <summary>
/// Gets the applicable tactic IDs.
/// </summary>
public ImmutableArray<string> TacticIds { get; init; } = [];
/// <summary>
/// Gets the number of known issues found.
/// </summary>
public int KnownIssueCount { get; init; }
}
/// <summary>
/// Content for error events.
/// </summary>
public sealed record ErrorContent : RunEventContent
{
/// <summary>
/// Gets the error code.
/// </summary>
public required string ErrorCode { get; init; }
/// <summary>
/// Gets the error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets the stack trace (if available).
/// </summary>
public string? StackTrace { get; init; }
/// <summary>
/// Gets whether the error is recoverable.
/// </summary>
public bool IsRecoverable { get; init; }
}
/// <summary>
/// Reference to an evidence link.
/// </summary>
public sealed record EvidenceLink
{
/// <summary>
/// Gets the evidence URI.
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Gets the evidence type.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Gets the content digest.
/// </summary>
public string? Digest { get; init; }
/// <summary>
/// Gets the evidence label/description.
/// </summary>
public string? Label { get; init; }
}

View File

@@ -0,0 +1,723 @@
// <copyright file="RunService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.AdvisoryAI.Runs;
/// <summary>
/// Implementation of the run service.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-003
/// </summary>
internal sealed class RunService : IRunService
{
private readonly IRunStore _store;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RunService> _logger;
private readonly IGuidGenerator _guidGenerator;
/// <summary>
/// Initializes a new instance of the <see cref="RunService"/> class.
/// </summary>
public RunService(
IRunStore store,
TimeProvider timeProvider,
IGuidGenerator guidGenerator,
ILogger<RunService> logger)
{
_store = store;
_timeProvider = timeProvider;
_guidGenerator = guidGenerator;
_logger = logger;
}
/// <inheritdoc />
public async Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(request.InitiatedBy);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Title);
var now = _timeProvider.GetUtcNow();
var runId = GenerateRunId(now);
var createdEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.Created,
Timestamp = now,
ActorId = request.InitiatedBy,
SequenceNumber = 0,
Content = new StatusChangedContent
{
FromStatus = RunStatus.Created,
ToStatus = RunStatus.Created,
Reason = "Run created"
}
};
var run = new Run
{
RunId = runId,
TenantId = request.TenantId,
InitiatedBy = request.InitiatedBy,
Title = request.Title,
Objective = request.Objective,
Status = RunStatus.Created,
CreatedAt = now,
UpdatedAt = now,
Context = request.Context ?? new RunContext(),
Events = [createdEvent],
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
};
await _store.SaveAsync(run, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created run {RunId} for tenant {TenantId} by user {UserId}",
runId, request.TenantId, request.InitiatedBy);
return run;
}
/// <inheritdoc />
public async Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var (runs, totalCount) = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
return new RunQueryResult
{
Runs = runs,
TotalCount = totalCount,
HasMore = totalCount > query.Skip + runs.Length
};
}
/// <inheritdoc />
public async Task<RunEvent> AddEventAsync(
string tenantId,
string runId,
AddRunEventRequest eventRequest,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var newEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = eventRequest.Type,
Timestamp = now,
ActorId = eventRequest.ActorId,
Content = eventRequest.Content,
EvidenceLinks = eventRequest.EvidenceLinks ?? [],
SequenceNumber = run.Events.Length,
ParentEventId = eventRequest.ParentEventId,
Metadata = eventRequest.Metadata ?? ImmutableDictionary<string, string>.Empty
};
var updatedRun = run with
{
Events = run.Events.Add(newEvent),
UpdatedAt = now,
Status = run.Status == RunStatus.Created ? RunStatus.Active : run.Status
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
return newEvent;
}
/// <inheritdoc />
public async Task<RunEvent> AddUserTurnAsync(
string tenantId,
string runId,
string message,
string userId,
ImmutableArray<EvidenceLink>? evidenceLinks = null,
CancellationToken cancellationToken = default)
{
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
{
Type = RunEventType.UserTurn,
ActorId = userId,
Content = new TurnContent
{
Message = message,
Role = "user"
},
EvidenceLinks = evidenceLinks
}, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<RunEvent> AddAssistantTurnAsync(
string tenantId,
string runId,
string message,
ImmutableArray<EvidenceLink>? evidenceLinks = null,
CancellationToken cancellationToken = default)
{
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
{
Type = RunEventType.AssistantTurn,
ActorId = "assistant",
Content = new TurnContent
{
Message = message,
Role = "assistant"
},
EvidenceLinks = evidenceLinks
}, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<RunEvent> ProposeActionAsync(
string tenantId,
string runId,
ProposeActionRequest action,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(action);
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
{
Type = RunEventType.ActionProposed,
ActorId = "assistant",
Content = new ActionProposedContent
{
ActionType = action.ActionType,
Subject = action.Subject,
Rationale = action.Rationale,
RequiresApproval = action.RequiresApproval,
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty
},
EvidenceLinks = action.EvidenceLinks
}, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Run> RequestApprovalAsync(
string tenantId,
string runId,
ImmutableArray<string> approvers,
string? reason = null,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var approvalEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.ApprovalRequested,
Timestamp = now,
ActorId = "system",
SequenceNumber = run.Events.Length,
Content = new StatusChangedContent
{
FromStatus = run.Status,
ToStatus = RunStatus.PendingApproval,
Reason = reason ?? "Approval requested for proposed actions"
}
};
var updatedRun = run with
{
Status = RunStatus.PendingApproval,
UpdatedAt = now,
Events = run.Events.Add(approvalEvent),
Approval = new ApprovalInfo
{
Required = true,
Approvers = approvers
}
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Approval requested for run {RunId} from approvers: {Approvers}",
runId, string.Join(", ", approvers));
return updatedRun;
}
/// <inheritdoc />
public async Task<Run> ApproveAsync(
string tenantId,
string runId,
bool approved,
string approverId,
string? reason = null,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (run.Status != RunStatus.PendingApproval)
{
throw new InvalidOperationException($"Run {runId} is not pending approval. Current status: {run.Status}");
}
var now = _timeProvider.GetUtcNow();
var newStatus = approved ? RunStatus.Approved : RunStatus.Rejected;
var eventType = approved ? RunEventType.ApprovalGranted : RunEventType.ApprovalDenied;
var approvalEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = eventType,
Timestamp = now,
ActorId = approverId,
SequenceNumber = run.Events.Length,
Content = new StatusChangedContent
{
FromStatus = run.Status,
ToStatus = newStatus,
Reason = reason ?? (approved ? "Approved" : "Rejected")
}
};
var updatedRun = run with
{
Status = newStatus,
UpdatedAt = now,
Events = run.Events.Add(approvalEvent),
Approval = run.Approval! with
{
Approved = approved,
ApprovedBy = approverId,
ApprovedAt = now,
Reason = reason
},
CompletedAt = approved ? null : now
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Run {RunId} {Action} by {ApproverId}: {Reason}",
runId, approved ? "approved" : "rejected", approverId, reason ?? "(no reason)");
return updatedRun;
}
/// <inheritdoc />
public async Task<RunEvent> ExecuteActionAsync(
string tenantId,
string runId,
string actionEventId,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (run.Status != RunStatus.Approved && run.Status != RunStatus.Active)
{
throw new InvalidOperationException($"Cannot execute actions on run with status: {run.Status}");
}
var actionEvent = run.Events.FirstOrDefault(e => e.EventId == actionEventId);
if (actionEvent is null)
{
throw new InvalidOperationException($"Action event {actionEventId} not found in run {runId}");
}
if (actionEvent.Type != RunEventType.ActionProposed)
{
throw new InvalidOperationException($"Event {actionEventId} is not an action proposal");
}
// In a real implementation, this would execute the action
// For now, we just record that it was executed
var now = _timeProvider.GetUtcNow();
var actionContent = actionEvent.Content as ActionProposedContent;
var executedEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.ActionExecuted,
Timestamp = now,
ActorId = "system",
SequenceNumber = run.Events.Length,
ParentEventId = actionEventId,
Content = new ActionExecutedContent
{
ActionType = actionContent?.ActionType ?? "unknown",
Success = true,
ResultSummary = $"Action {actionContent?.ActionType} executed successfully"
}
};
var updatedRun = run with
{
Events = run.Events.Add(executedEvent),
UpdatedAt = now
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Executed action {ActionType} in run {RunId}",
actionContent?.ActionType ?? "unknown", runId);
return executedEvent;
}
/// <inheritdoc />
public async Task<Run> AddArtifactAsync(
string tenantId,
string runId,
RunArtifact artifact,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var artifactEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.ArtifactProduced,
Timestamp = now,
ActorId = "system",
SequenceNumber = run.Events.Length,
Content = new ArtifactProducedContent
{
ArtifactId = artifact.ArtifactId,
ArtifactType = artifact.Type.ToString(),
Name = artifact.Name,
ContentDigest = artifact.ContentDigest
}
};
var updatedRun = run with
{
Artifacts = run.Artifacts.Add(artifact),
Events = run.Events.Add(artifactEvent),
UpdatedAt = now
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
return updatedRun;
}
/// <inheritdoc />
public async Task<Run> AttachEvidencePackAsync(
string tenantId,
string runId,
EvidencePackReference evidencePack,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var evidenceEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.EvidenceAttached,
Timestamp = now,
ActorId = "system",
SequenceNumber = run.Events.Length
};
var updatedRun = run with
{
EvidencePacks = run.EvidencePacks.Add(evidencePack),
Events = run.Events.Add(evidenceEvent),
UpdatedAt = now
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
return updatedRun;
}
/// <inheritdoc />
public async Task<Run> CompleteAsync(
string tenantId,
string runId,
string? summary = null,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var completedEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.Completed,
Timestamp = now,
ActorId = "system",
SequenceNumber = run.Events.Length,
Content = new StatusChangedContent
{
FromStatus = run.Status,
ToStatus = RunStatus.Completed,
Reason = summary ?? "Run completed"
}
};
var contentDigest = ComputeContentDigest(run);
var updatedRun = run with
{
Status = RunStatus.Completed,
CompletedAt = now,
UpdatedAt = now,
Events = run.Events.Add(completedEvent),
ContentDigest = contentDigest
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Completed run {RunId} with digest {Digest}", runId, contentDigest);
return updatedRun;
}
/// <inheritdoc />
public async Task<Run> CancelAsync(
string tenantId,
string runId,
string? reason = null,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var cancelledEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.Cancelled,
Timestamp = now,
ActorId = "system",
SequenceNumber = run.Events.Length,
Content = new StatusChangedContent
{
FromStatus = run.Status,
ToStatus = RunStatus.Cancelled,
Reason = reason ?? "Run cancelled"
}
};
var updatedRun = run with
{
Status = RunStatus.Cancelled,
CompletedAt = now,
UpdatedAt = now,
Events = run.Events.Add(cancelledEvent)
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Cancelled run {RunId}: {Reason}", runId, reason ?? "(no reason)");
return updatedRun;
}
/// <inheritdoc />
public async Task<Run> HandOffAsync(
string tenantId,
string runId,
string toUserId,
string? message = null,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
ValidateCanModify(run);
var now = _timeProvider.GetUtcNow();
var handoffEvent = new RunEvent
{
EventId = GenerateEventId(),
Type = RunEventType.HandedOff,
Timestamp = now,
ActorId = run.InitiatedBy,
SequenceNumber = run.Events.Length,
Content = new TurnContent
{
Message = message ?? $"Handed off to {toUserId}",
Role = "system"
},
Metadata = new Dictionary<string, string>
{
["to_user"] = toUserId
}.ToImmutableDictionary()
};
var updatedRun = run with
{
Events = run.Events.Add(handoffEvent),
UpdatedAt = now,
Metadata = run.Metadata.SetItem("current_owner", toUserId)
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Run {RunId} handed off to {UserId}", runId, toUserId);
return updatedRun;
}
/// <inheritdoc />
public async Task<Run> AttestAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (run.Status != RunStatus.Completed)
{
throw new InvalidOperationException($"Cannot attest run that is not completed. Current status: {run.Status}");
}
if (run.Attestation is not null)
{
throw new InvalidOperationException($"Run {runId} is already attested");
}
var contentDigest = run.ContentDigest ?? ComputeContentDigest(run);
var now = _timeProvider.GetUtcNow();
// In a real implementation, this would sign the attestation
var attestation = new RunAttestation
{
AttestationId = $"att-{_guidGenerator.NewGuid():N}",
ContentDigest = contentDigest,
StatementUri = $"stellaops://runs/{runId}/attestation",
Signature = "placeholder-signature", // Would be DSSE signature
CreatedAt = now
};
var updatedRun = run with
{
Attestation = attestation,
ContentDigest = contentDigest,
UpdatedAt = now
};
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Attested run {RunId} with attestation {AttestationId}", runId, attestation.AttestationId);
return updatedRun;
}
/// <inheritdoc />
public async Task<ImmutableArray<RunEvent>> GetTimelineAsync(
string tenantId,
string runId,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
var run = await GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
return [];
}
return run.Events
.OrderBy(e => e.SequenceNumber)
.Skip(skip)
.Take(take)
.ToImmutableArray();
}
private async Task<Run> GetRequiredRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken)
{
var run = await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
throw new InvalidOperationException($"Run {runId} not found");
}
return run;
}
private static void ValidateCanModify(Run run)
{
if (run.Status is RunStatus.Completed or RunStatus.Cancelled or RunStatus.Failed or RunStatus.Expired)
{
throw new InvalidOperationException($"Cannot modify run with status: {run.Status}");
}
}
private string GenerateRunId(DateTimeOffset timestamp)
{
var ts = timestamp.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
var random = _guidGenerator.NewGuid().ToString("N")[..8];
return $"run-{ts}-{random}";
}
private string GenerateEventId()
{
return $"evt-{_guidGenerator.NewGuid():N}";
}
private static string ComputeContentDigest(Run run)
{
var content = new
{
run.RunId,
run.TenantId,
run.Title,
run.CreatedAt,
EventCount = run.Events.Length,
Events = run.Events.Select(e => new { e.EventId, e.Type, e.Timestamp }).ToArray(),
Artifacts = run.Artifacts.Select(a => new { a.ArtifactId, a.ContentDigest }).ToArray()
};
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Interface for generating GUIDs (injectable for testing).
/// </summary>
public interface IGuidGenerator
{
/// <summary>
/// Generates a new GUID.
/// </summary>
Guid NewGuid();
}
/// <summary>
/// Default GUID generator using Guid.NewGuid().
/// </summary>
public sealed class DefaultGuidGenerator : IGuidGenerator
{
/// <inheritdoc />
public Guid NewGuid() => Guid.NewGuid();
}

View File

@@ -17,6 +17,10 @@
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\OpsMemory\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,358 @@
// <copyright file="ActionExecutorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.AdvisoryAI.Actions;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Actions;
/// <summary>
/// Unit tests for ActionExecutor.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
/// </summary>
[Trait("Category", "Unit")]
public sealed class ActionExecutorTests
{
private readonly Mock<IActionPolicyGate> _policyGateMock;
private readonly ActionRegistry _actionRegistry;
private readonly Mock<IIdempotencyHandler> _idempotencyMock;
private readonly Mock<IApprovalWorkflowAdapter> _approvalMock;
private readonly Mock<IActionAuditLedger> _auditLedgerMock;
private readonly FakeTimeProvider _timeProvider;
private readonly FakeGuidGenerator _guidGenerator;
private readonly ActionExecutor _sut;
public ActionExecutorTests()
{
_policyGateMock = new Mock<IActionPolicyGate>();
_actionRegistry = new ActionRegistry();
_idempotencyMock = new Mock<IIdempotencyHandler>();
_approvalMock = new Mock<IApprovalWorkflowAdapter>();
_auditLedgerMock = new Mock<IActionAuditLedger>();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
_guidGenerator = new FakeGuidGenerator();
var options = Options.Create(new ActionExecutorOptions
{
EnableIdempotency = true,
EnableAuditLogging = true
});
_sut = new ActionExecutor(
_policyGateMock.Object,
_actionRegistry,
_idempotencyMock.Object,
_approvalMock.Object,
_auditLedgerMock.Object,
_timeProvider,
_guidGenerator,
options,
NullLogger<ActionExecutor>.Instance);
// Default idempotency behavior: not executed
_idempotencyMock
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdempotencyResult.NotExecuted);
_idempotencyMock
.Setup(x => x.GenerateKey(It.IsAny<ActionProposal>(), It.IsAny<ActionContext>()))
.Returns("test-idempotency-key");
}
[Fact]
public async Task ExecuteAsync_ExecutesImmediately_WhenPolicyAllows()
{
// Arrange
var proposal = CreateProposal("defer");
var context = CreateContext();
var decision = CreateAllowDecision();
// Act
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
// Assert
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
result.ExecutionId.Should().NotBeNullOrEmpty();
_auditLedgerMock.Verify(
x => x.RecordAsync(
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.Executed),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_ReturnsPendingApproval_WhenApprovalRequired()
{
// Arrange
var proposal = CreateProposal("approve");
var context = CreateContext();
var decision = CreateApprovalRequiredDecision();
_approvalMock
.Setup(x => x.CreateApprovalRequestAsync(
It.IsAny<ActionProposal>(),
It.IsAny<ActionPolicyDecision>(),
It.IsAny<ActionContext>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ApprovalRequest
{
RequestId = "approval-123",
WorkflowId = "high-risk-approval",
TenantId = context.TenantId,
RequesterId = context.UserId,
RequiredApprovers = ImmutableArray.Create(
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
Timeout = TimeSpan.FromHours(4),
Payload = new ApprovalPayload
{
ActionType = proposal.ActionType,
ActionLabel = proposal.Label,
Parameters = proposal.Parameters
},
CreatedAt = _timeProvider.GetUtcNow()
});
// Act
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
// Assert
result.Outcome.Should().Be(ActionExecutionOutcome.PendingApproval);
result.OutputData.Should().ContainKey("approvalRequestId");
result.OutputData["approvalRequestId"].Should().Be("approval-123");
_auditLedgerMock.Verify(
x => x.RecordAsync(
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.ApprovalRequested),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_ReturnsFailed_WhenPolicyDenies()
{
// Arrange
var proposal = CreateProposal("quarantine");
var context = CreateContext();
var decision = CreateDenyDecision("Missing required role");
// Act
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
// Assert
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
result.Error.Should().NotBeNull();
result.Error!.Code.Should().Be("POLICY_DENIED");
_auditLedgerMock.Verify(
x => x.RecordAsync(
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.DeniedByPolicy),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_SkipsExecution_WhenIdempotent()
{
// Arrange
var proposal = CreateProposal("defer");
var context = CreateContext();
var decision = CreateAllowDecision();
var previousResult = new ActionExecutionResult
{
ExecutionId = "previous-exec-123",
Outcome = ActionExecutionOutcome.Success,
Message = "Previously executed",
StartedAt = _timeProvider.GetUtcNow().AddHours(-1),
CompletedAt = _timeProvider.GetUtcNow().AddHours(-1),
CanRollback = false
};
_idempotencyMock
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new IdempotencyResult
{
AlreadyExecuted = true,
PreviousResult = previousResult,
ExecutedAt = _timeProvider.GetUtcNow().AddHours(-1)
});
// Act
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
// Assert
result.Should().BeSameAs(previousResult);
_auditLedgerMock.Verify(
x => x.RecordAsync(
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.IdempotentSkipped),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_RecordsIdempotency_OnSuccess()
{
// Arrange
var proposal = CreateProposal("defer");
var context = CreateContext();
var decision = CreateAllowDecision();
// Act
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
// Assert
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
_idempotencyMock.Verify(
x => x.RecordExecutionAsync(
It.IsAny<string>(),
It.Is<ActionExecutionResult>(r => r.Outcome == ActionExecutionOutcome.Success),
It.IsAny<ActionContext>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_IncludesOverrideInfo_ForDenyWithOverride()
{
// Arrange
var proposal = CreateProposal("approve");
var context = CreateContext();
var decision = new ActionPolicyDecision
{
Decision = PolicyDecisionKind.DenyWithOverride,
PolicyId = "high-risk-check",
Reason = "Additional approval needed"
};
// Act
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
// Assert
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
result.Error!.Code.Should().Be("POLICY_DENIED_OVERRIDE_AVAILABLE");
result.OutputData.Should().ContainKey("overrideAvailable");
result.OutputData["overrideAvailable"].Should().Be("true");
}
[Fact]
public void GetSupportedActionTypes_ReturnsAllActions()
{
// Act
var actionTypes = _sut.GetSupportedActionTypes();
// Assert
actionTypes.Should().NotBeEmpty();
actionTypes.Should().Contain(a => a.Type == "approve");
actionTypes.Should().Contain(a => a.Type == "quarantine");
actionTypes.Should().Contain(a => a.Type == "defer");
}
[Fact]
public void GetSupportedActionTypes_IncludesMetadata()
{
// Act
var actionTypes = _sut.GetSupportedActionTypes();
var approveAction = actionTypes.Single(a => a.Type == "approve");
// Assert
approveAction.DisplayName.Should().Be("Approve Risk");
approveAction.RequiredPermission.Should().NotBeNullOrEmpty();
approveAction.SupportsRollback.Should().BeTrue();
approveAction.IsDestructive.Should().BeTrue(); // High risk
}
private static ActionProposal CreateProposal(string actionType)
{
var parameters = actionType switch
{
"approve" => new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487",
["justification"] = "Risk accepted"
},
"quarantine" => new Dictionary<string, string>
{
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
["reason"] = "Critical vulnerability"
},
"defer" => new Dictionary<string, string>
{
["finding_id"] = "finding-123",
["defer_days"] = "30",
["reason"] = "Scheduled for next sprint"
},
_ => new Dictionary<string, string>()
};
return new ActionProposal
{
ProposalId = Guid.NewGuid().ToString(),
ActionType = actionType,
Label = $"Test {actionType}",
Parameters = parameters.ToImmutableDictionary(),
CreatedAt = DateTimeOffset.UtcNow
};
}
private static ActionContext CreateContext()
{
return new ActionContext
{
TenantId = "test-tenant",
UserId = "test-user",
UserRoles = ImmutableArray.Create("security-analyst"),
Environment = "development"
};
}
private static ActionPolicyDecision CreateAllowDecision()
{
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.Allow,
PolicyId = "test-policy",
Reason = "Allowed"
};
}
private static ActionPolicyDecision CreateApprovalRequiredDecision()
{
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.AllowWithApproval,
PolicyId = "high-risk-approval",
Reason = "High-risk action requires approval",
ApprovalWorkflowId = "action-approval-high",
RequiredApprovers = ImmutableArray.Create(
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(4)
};
}
private static ActionPolicyDecision CreateDenyDecision(string reason)
{
return new ActionPolicyDecision
{
Decision = PolicyDecisionKind.Deny,
PolicyId = "role-check",
Reason = reason
};
}
}
/// <summary>
/// Fake GUID generator for deterministic testing.
/// </summary>
internal sealed class FakeGuidGenerator : IGuidGenerator
{
private int _counter;
public Guid NewGuid() => new($"00000000-0000-0000-0000-{_counter++:D12}");
}

View File

@@ -0,0 +1,311 @@
// <copyright file="ActionPolicyGateTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Actions;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Actions;
/// <summary>
/// Unit tests for ActionPolicyGate.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
/// </summary>
[Trait("Category", "Unit")]
public sealed class ActionPolicyGateTests
{
private readonly ActionPolicyGate _sut;
private readonly FakeTimeProvider _timeProvider;
public ActionPolicyGateTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new ActionPolicyOptions
{
DefaultTimeoutHours = 4,
CriticalTimeoutHours = 24
});
_sut = new ActionPolicyGate(
new ActionRegistry(),
_timeProvider,
options,
NullLogger<ActionPolicyGate>.Instance);
}
[Fact]
public async Task EvaluateAsync_AllowsLowRiskAction_WithAnyRole()
{
// Arrange
var proposal = CreateProposal("defer", new Dictionary<string, string>
{
["finding_id"] = "finding-123",
["defer_days"] = "30",
["reason"] = "Scheduled for next sprint"
});
var context = CreateContext("security-analyst");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.Allow);
result.PolicyId.Should().Be("low-risk-auto-allow");
}
[Fact]
public async Task EvaluateAsync_RequiresApproval_ForMediumRiskWithoutElevatedRole()
{
// Arrange
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487",
["status"] = "not_affected",
["justification"] = "Component not in use"
});
var context = CreateContext("security-analyst");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
result.RequiredApprovers.Should().Contain(a => a.Identifier == "team-lead");
}
[Fact]
public async Task EvaluateAsync_AllowsMediumRiskAction_ForSecurityLead()
{
// Arrange
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487",
["status"] = "not_affected",
["justification"] = "Component not in use"
});
var context = CreateContext("security-lead");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.Allow);
}
[Fact]
public async Task EvaluateAsync_RequiresApproval_ForHighRiskWithoutAdminRole()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487",
["justification"] = "Risk accepted"
});
var context = CreateContext("security-analyst");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
}
[Fact]
public async Task EvaluateAsync_AllowsHighRiskAction_ForAdmin()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487",
["justification"] = "Risk accepted"
});
var context = CreateContext("admin");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.Allow);
}
[Fact]
public async Task EvaluateAsync_RequiresMultiPartyApproval_ForCriticalActionInProduction()
{
// Arrange
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
{
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
["reason"] = "Critical vulnerability"
});
var context = CreateContext("security-lead", environment: "production");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
result.RequiredApprovers.Should().HaveCountGreaterThan(1);
result.RequiredApprovers.Should().Contain(a => a.Identifier == "ciso");
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
}
[Fact]
public async Task EvaluateAsync_RequiresSingleApproval_ForCriticalActionInNonProduction()
{
// Arrange
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
{
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
["reason"] = "Critical vulnerability"
});
var context = CreateContext("security-lead", environment: "staging");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
result.RequiredApprovers.Should().HaveCount(1);
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
}
[Fact]
public async Task EvaluateAsync_DeniesAction_ForMissingRole()
{
// Arrange
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
{
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
["reason"] = "Critical vulnerability"
});
var context = CreateContext("viewer"); // Not a security-lead
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.Deny);
result.Reason.Should().Contain("role");
}
[Fact]
public async Task EvaluateAsync_DeniesAction_ForUnknownActionType()
{
// Arrange
var proposal = CreateProposal("unknown-action", new Dictionary<string, string>());
var context = CreateContext("admin");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.Deny);
result.Reason.Should().Contain("Unknown action type");
}
[Fact]
public async Task EvaluateAsync_DeniesAction_ForInvalidParameters()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "invalid-cve"
// Missing required justification
});
var context = CreateContext("admin");
// Act
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
// Assert
result.Decision.Should().Be(PolicyDecisionKind.Deny);
result.Reason.Should().Contain("Invalid parameters");
}
[Fact]
public async Task ExplainAsync_ReturnsSummary_ForAllowDecision()
{
// Arrange
var decision = new ActionPolicyDecision
{
Decision = PolicyDecisionKind.Allow,
PolicyId = "low-risk-auto-allow",
Reason = "Low-risk action allowed automatically"
};
// Act
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
// Assert
explanation.Summary.Should().Contain("allowed");
explanation.Details.Should().NotBeEmpty();
}
[Fact]
public async Task ExplainAsync_IncludesApprovers_ForApprovalDecision()
{
// Arrange
var decision = new ActionPolicyDecision
{
Decision = PolicyDecisionKind.AllowWithApproval,
PolicyId = "high-risk-approval",
Reason = "High-risk action requires approval",
RequiredApprovers = ImmutableArray.Create(
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" })
};
// Act
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
// Assert
explanation.Summary.Should().Contain("approval");
explanation.SuggestedActions.Should().Contain(a => a.Contains("security-lead"));
}
private static ActionProposal CreateProposal(string actionType, Dictionary<string, string> parameters)
{
return new ActionProposal
{
ProposalId = Guid.NewGuid().ToString(),
ActionType = actionType,
Label = $"Test {actionType}",
Parameters = parameters.ToImmutableDictionary(),
CreatedAt = DateTimeOffset.UtcNow
};
}
private static ActionContext CreateContext(string role, string environment = "development")
{
return new ActionContext
{
TenantId = "test-tenant",
UserId = "test-user",
UserRoles = ImmutableArray.Create(role),
Environment = environment
};
}
}
/// <summary>
/// Fake TimeProvider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow;
}

View File

@@ -0,0 +1,243 @@
// <copyright file="ActionRegistryTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.AdvisoryAI.Actions;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Actions;
/// <summary>
/// Unit tests for ActionRegistry.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
/// </summary>
[Trait("Category", "Unit")]
public sealed class ActionRegistryTests
{
private readonly ActionRegistry _sut = new();
[Fact]
public void GetAction_ReturnsDefinition_ForApproveAction()
{
// Act
var action = _sut.GetAction("approve");
// Assert
action.Should().NotBeNull();
action!.ActionType.Should().Be("approve");
action.DisplayName.Should().Be("Approve Risk");
action.RiskLevel.Should().Be(ActionRiskLevel.High);
action.IsIdempotent.Should().BeTrue();
action.HasCompensation.Should().BeTrue();
action.CompensationActionType.Should().Be("revoke_approval");
}
[Fact]
public void GetAction_ReturnsDefinition_ForQuarantineAction()
{
// Act
var action = _sut.GetAction("quarantine");
// Assert
action.Should().NotBeNull();
action!.ActionType.Should().Be("quarantine");
action.RiskLevel.Should().Be(ActionRiskLevel.Critical);
action.RequiredRole.Should().Be("security-lead");
}
[Fact]
public void GetAction_ReturnsNull_ForUnknownAction()
{
// Act
var action = _sut.GetAction("unknown-action");
// Assert
action.Should().BeNull();
}
[Fact]
public void GetAction_IsCaseInsensitive()
{
// Act
var lower = _sut.GetAction("approve");
var upper = _sut.GetAction("APPROVE");
var mixed = _sut.GetAction("Approve");
// Assert
lower.Should().NotBeNull();
upper.Should().NotBeNull();
mixed.Should().NotBeNull();
lower!.ActionType.Should().Be(upper!.ActionType);
lower.ActionType.Should().Be(mixed!.ActionType);
}
[Fact]
public void GetAllActions_ReturnsAllBuiltInActions()
{
// Act
var actions = _sut.GetAllActions();
// Assert
actions.Should().NotBeEmpty();
actions.Should().Contain(a => a.ActionType == "approve");
actions.Should().Contain(a => a.ActionType == "quarantine");
actions.Should().Contain(a => a.ActionType == "defer");
actions.Should().Contain(a => a.ActionType == "create_vex");
actions.Should().Contain(a => a.ActionType == "generate_manifest");
}
[Fact]
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForLowRisk()
{
// Act
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Low);
// Assert
actions.Should().NotBeEmpty();
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Low));
actions.Should().Contain(a => a.ActionType == "defer");
actions.Should().Contain(a => a.ActionType == "generate_manifest");
}
[Fact]
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForCriticalRisk()
{
// Act
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Critical);
// Assert
actions.Should().NotBeEmpty();
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Critical));
actions.Should().Contain(a => a.ActionType == "quarantine");
}
[Fact]
public void GetActionsByTag_ReturnsCorrectActions_ForCveTag()
{
// Act
var actions = _sut.GetActionsByTag("cve");
// Assert
actions.Should().NotBeEmpty();
actions.Should().Contain(a => a.ActionType == "approve");
actions.Should().Contain(a => a.ActionType == "revoke_approval");
}
[Fact]
public void GetActionsByTag_ReturnsCorrectActions_ForVexTag()
{
// Act
var actions = _sut.GetActionsByTag("vex");
// Assert
actions.Should().NotBeEmpty();
actions.Should().Contain(a => a.ActionType == "create_vex");
actions.Should().Contain(a => a.ActionType == "approve");
}
[Fact]
public void ValidateParameters_ReturnsSuccess_WithValidParameters()
{
// Arrange
var parameters = new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487",
["justification"] = "Risk accepted for internal service"
}.ToImmutableDictionary();
// Act
var result = _sut.ValidateParameters("approve", parameters);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ValidateParameters_ReturnsFailure_ForMissingRequiredParameter()
{
// Arrange
var parameters = new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
// Missing required "justification" parameter
}.ToImmutableDictionary();
// Act
var result = _sut.ValidateParameters("approve", parameters);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("justification"));
}
[Fact]
public void ValidateParameters_ReturnsFailure_ForInvalidCveIdPattern()
{
// Arrange
var parameters = new Dictionary<string, string>
{
["cve_id"] = "invalid-cve",
["justification"] = "Test"
}.ToImmutableDictionary();
// Act
var result = _sut.ValidateParameters("approve", parameters);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("cve_id"));
}
[Fact]
public void ValidateParameters_ReturnsFailure_ForUnknownActionType()
{
// Arrange
var parameters = ImmutableDictionary<string, string>.Empty;
// Act
var result = _sut.ValidateParameters("unknown-action", parameters);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Unknown action type"));
}
[Fact]
public void ValidateParameters_AcceptsValidImageDigest()
{
// Arrange
var parameters = new Dictionary<string, string>
{
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
["reason"] = "Critical vulnerability",
["cve_ids"] = "CVE-2023-44487"
}.ToImmutableDictionary();
// Act
var result = _sut.ValidateParameters("quarantine", parameters);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ValidateParameters_RejectsInvalidImageDigest()
{
// Arrange
var parameters = new Dictionary<string, string>
{
["image_digest"] = "invalid-digest",
["reason"] = "Critical vulnerability"
}.ToImmutableDictionary();
// Act
var result = _sut.ValidateParameters("quarantine", parameters);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("image_digest"));
}
}

View File

@@ -0,0 +1,309 @@
// <copyright file="IdempotencyHandlerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Actions;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Actions;
/// <summary>
/// Unit tests for IdempotencyHandler.
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
/// </summary>
[Trait("Category", "Unit")]
public sealed class IdempotencyHandlerTests
{
private readonly IdempotencyHandler _sut;
private readonly FakeTimeProvider _timeProvider;
public IdempotencyHandlerTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new IdempotencyOptions { TtlDays = 30 });
_sut = new IdempotencyHandler(
_timeProvider,
options,
NullLogger<IdempotencyHandler>.Instance);
}
[Fact]
public void GenerateKey_IsDeterministic()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context = CreateContext("tenant-1");
// Act
var key1 = _sut.GenerateKey(proposal, context);
var key2 = _sut.GenerateKey(proposal, context);
// Assert
key1.Should().Be(key2);
key1.Should().HaveLength(64); // SHA-256 hex length
}
[Fact]
public void GenerateKey_DiffersByTenant()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context1 = CreateContext("tenant-1");
var context2 = CreateContext("tenant-2");
// Act
var key1 = _sut.GenerateKey(proposal, context1);
var key2 = _sut.GenerateKey(proposal, context2);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public void GenerateKey_DiffersByActionType()
{
// Arrange
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var proposal2 = CreateProposal("defer", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context = CreateContext("tenant-1");
// Act
var key1 = _sut.GenerateKey(proposal1, context);
var key2 = _sut.GenerateKey(proposal2, context);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public void GenerateKey_DiffersByCveId()
{
// Arrange
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2024-12345"
});
var context = CreateContext("tenant-1");
// Act
var key1 = _sut.GenerateKey(proposal1, context);
var key2 = _sut.GenerateKey(proposal2, context);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public void GenerateKey_DiffersByImageDigest()
{
// Arrange
var proposal1 = CreateProposal("quarantine", new Dictionary<string, string>
{
["image_digest"] = "sha256:aaaa"
});
var proposal2 = CreateProposal("quarantine", new Dictionary<string, string>
{
["image_digest"] = "sha256:bbbb"
});
var context = CreateContext("tenant-1");
// Act
var key1 = _sut.GenerateKey(proposal1, context);
var key2 = _sut.GenerateKey(proposal2, context);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public void GenerateKey_IncludesExplicitIdempotencyKey()
{
// Arrange
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
}, idempotencyKey: "key-1");
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
}, idempotencyKey: "key-2");
var context = CreateContext("tenant-1");
// Act
var key1 = _sut.GenerateKey(proposal1, context);
var key2 = _sut.GenerateKey(proposal2, context);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public async Task CheckAsync_ReturnsNotExecuted_WhenNoRecord()
{
// Arrange
var key = "non-existent-key";
// Act
var result = await _sut.CheckAsync(key, CancellationToken.None);
// Assert
result.AlreadyExecuted.Should().BeFalse();
result.PreviousResult.Should().BeNull();
}
[Fact]
public async Task CheckAsync_ReturnsExecuted_WhenRecorded()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context = CreateContext("tenant-1");
var key = _sut.GenerateKey(proposal, context);
var executionResult = CreateExecutionResult("exec-123");
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
// Act
var result = await _sut.CheckAsync(key, CancellationToken.None);
// Assert
result.AlreadyExecuted.Should().BeTrue();
result.PreviousResult.Should().NotBeNull();
result.PreviousResult!.ExecutionId.Should().Be("exec-123");
result.ExecutedBy.Should().Be("test-user");
}
[Fact]
public async Task CheckAsync_ReturnsNotExecuted_WhenExpired()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context = CreateContext("tenant-1");
var key = _sut.GenerateKey(proposal, context);
var executionResult = CreateExecutionResult("exec-123");
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
// Advance past TTL
_timeProvider.Advance(TimeSpan.FromDays(31));
// Act
var result = await _sut.CheckAsync(key, CancellationToken.None);
// Assert
result.AlreadyExecuted.Should().BeFalse();
}
[Fact]
public async Task RemoveAsync_DeletesRecord()
{
// Arrange
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context = CreateContext("tenant-1");
var key = _sut.GenerateKey(proposal, context);
var executionResult = CreateExecutionResult("exec-123");
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
// Act
await _sut.RemoveAsync(key, CancellationToken.None);
var result = await _sut.CheckAsync(key, CancellationToken.None);
// Assert
result.AlreadyExecuted.Should().BeFalse();
}
[Fact]
public void CleanupExpired_RemovesExpiredRecords()
{
// Arrange - synchronous setup with async results
var proposal = CreateProposal("approve", new Dictionary<string, string>
{
["cve_id"] = "CVE-2023-44487"
});
var context = CreateContext("tenant-1");
var key = _sut.GenerateKey(proposal, context);
var executionResult = CreateExecutionResult("exec-123");
_sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None).Wait();
// Advance past TTL
_timeProvider.Advance(TimeSpan.FromDays(31));
// Act
_sut.CleanupExpired();
var result = _sut.CheckAsync(key, CancellationToken.None).Result;
// Assert
result.AlreadyExecuted.Should().BeFalse();
}
private static ActionProposal CreateProposal(
string actionType,
Dictionary<string, string> parameters,
string? idempotencyKey = null)
{
return new ActionProposal
{
ProposalId = Guid.NewGuid().ToString(),
ActionType = actionType,
Label = $"Test {actionType}",
Parameters = parameters.ToImmutableDictionary(),
CreatedAt = DateTimeOffset.UtcNow,
IdempotencyKey = idempotencyKey
};
}
private static ActionContext CreateContext(string tenantId)
{
return new ActionContext
{
TenantId = tenantId,
UserId = "test-user",
UserRoles = ImmutableArray.Create("security-analyst"),
Environment = "development"
};
}
private ActionExecutionResult CreateExecutionResult(string executionId)
{
return new ActionExecutionResult
{
ExecutionId = executionId,
Outcome = ActionExecutionOutcome.Success,
Message = "Success",
StartedAt = _timeProvider.GetUtcNow(),
CompletedAt = _timeProvider.GetUtcNow(),
CanRollback = false
};
}
}

View File

@@ -0,0 +1,301 @@
// <copyright file="InMemoryRunStoreTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Runs;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
/// <summary>
/// Unit tests for <see cref="InMemoryRunStore"/>.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
/// </summary>
[Trait("Category", "Unit")]
public sealed class InMemoryRunStoreTests
{
private readonly InMemoryRunStore _store = new();
[Fact]
public async Task SaveAsync_And_GetAsync_RoundTrip()
{
// Arrange
var run = CreateTestRun("run-1", "tenant-1");
// Act
await _store.SaveAsync(run);
var retrieved = await _store.GetAsync("tenant-1", "run-1");
// Assert
Assert.NotNull(retrieved);
Assert.Equal(run.RunId, retrieved.RunId);
Assert.Equal(run.TenantId, retrieved.TenantId);
Assert.Equal(run.Title, retrieved.Title);
}
[Fact]
public async Task GetAsync_DifferentTenant_ReturnsNull()
{
// Arrange
var run = CreateTestRun("run-1", "tenant-1");
await _store.SaveAsync(run);
// Act
var retrieved = await _store.GetAsync("tenant-2", "run-1");
// Assert
Assert.Null(retrieved);
}
[Fact]
public async Task DeleteAsync_ExistingRun_ReturnsTrue()
{
// Arrange
var run = CreateTestRun("run-1", "tenant-1");
await _store.SaveAsync(run);
// Act
var deleted = await _store.DeleteAsync("tenant-1", "run-1");
// Assert
Assert.True(deleted);
Assert.Null(await _store.GetAsync("tenant-1", "run-1"));
}
[Fact]
public async Task DeleteAsync_NonExistentRun_ReturnsFalse()
{
// Act
var deleted = await _store.DeleteAsync("tenant-1", "non-existent");
// Assert
Assert.False(deleted);
}
[Fact]
public async Task QueryAsync_FiltersByTenant()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1"));
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1"));
await _store.SaveAsync(CreateTestRun("run-3", "tenant-2"));
// Act
var (runs, count) = await _store.QueryAsync(new RunQuery { TenantId = "tenant-1" });
// Assert
Assert.Equal(2, count);
Assert.All(runs, r => Assert.Equal("tenant-1", r.TenantId));
}
[Fact]
public async Task QueryAsync_FiltersByStatus()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.Completed });
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Active });
// Act
var (runs, count) = await _store.QueryAsync(new RunQuery
{
TenantId = "tenant-1",
Statuses = [RunStatus.Active]
});
// Assert
Assert.Equal(2, count);
Assert.All(runs, r => Assert.Equal(RunStatus.Active, r.Status));
}
[Fact]
public async Task QueryAsync_FiltersByInitiator()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1"));
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2"));
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1"));
// Act
var (runs, count) = await _store.QueryAsync(new RunQuery
{
TenantId = "tenant-1",
InitiatedBy = "user-1"
});
// Assert
Assert.Equal(2, count);
Assert.All(runs, r => Assert.Equal("user-1", r.InitiatedBy));
}
[Fact]
public async Task QueryAsync_FiltersByCveId()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", cveId: "CVE-2024-1111"));
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", cveId: "CVE-2024-2222"));
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", cveId: "CVE-2024-1111"));
// Act
var (runs, count) = await _store.QueryAsync(new RunQuery
{
TenantId = "tenant-1",
CveId = "CVE-2024-1111"
});
// Assert
Assert.Equal(2, count);
Assert.All(runs, r => Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId));
}
[Fact]
public async Task QueryAsync_Pagination_WorksCorrectly()
{
// Arrange
for (int i = 0; i < 10; i++)
{
await _store.SaveAsync(CreateTestRun($"run-{i}", "tenant-1"));
}
// Act
var (page1, total1) = await _store.QueryAsync(new RunQuery
{
TenantId = "tenant-1",
Skip = 0,
Take = 3
});
var (page2, total2) = await _store.QueryAsync(new RunQuery
{
TenantId = "tenant-1",
Skip = 3,
Take = 3
});
// Assert
Assert.Equal(10, total1);
Assert.Equal(10, total2);
Assert.Equal(3, page1.Length);
Assert.Equal(3, page2.Length);
Assert.True(page1.All(r => !page2.Contains(r)));
}
[Fact]
public async Task GetByStatusAsync_ReturnsCorrectRuns()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.PendingApproval });
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Completed });
// Act
var runs = await _store.GetByStatusAsync(
"tenant-1", [RunStatus.Active, RunStatus.PendingApproval]);
// Assert
Assert.Equal(2, runs.Length);
Assert.DoesNotContain(runs, r => r.Status == RunStatus.Completed);
}
[Fact]
public async Task GetActiveForUserAsync_ReturnsUserRuns()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Active });
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2") with { Status = RunStatus.Active });
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Completed });
// Act
var runs = await _store.GetActiveForUserAsync("tenant-1", "user-1");
// Assert
Assert.Single(runs);
Assert.Equal("user-1", runs[0].InitiatedBy);
Assert.Equal(RunStatus.Active, runs[0].Status);
}
[Fact]
public async Task GetPendingApprovalAsync_ReturnsAllPending()
{
// Arrange
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with
{
Status = RunStatus.PendingApproval,
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-1"] }
});
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with
{
Status = RunStatus.PendingApproval,
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-2"] }
});
// Act - no filter
var allPending = await _store.GetPendingApprovalAsync("tenant-1");
// Act - with filter
var approver1Pending = await _store.GetPendingApprovalAsync("tenant-1", "approver-1");
// Assert
Assert.Equal(2, allPending.Length);
Assert.Single(approver1Pending);
}
[Fact]
public async Task UpdateStatusAsync_UpdatesStatus()
{
// Arrange
var run = CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active };
await _store.SaveAsync(run);
// Act
var updated = await _store.UpdateStatusAsync("tenant-1", "run-1", RunStatus.Completed);
// Assert
Assert.True(updated);
var retrieved = await _store.GetAsync("tenant-1", "run-1");
Assert.Equal(RunStatus.Completed, retrieved!.Status);
}
[Fact]
public async Task UpdateStatusAsync_NonExistent_ReturnsFalse()
{
// Act
var updated = await _store.UpdateStatusAsync("tenant-1", "non-existent", RunStatus.Active);
// Assert
Assert.False(updated);
}
[Fact]
public void Clear_RemovesAllRuns()
{
// Arrange
_store.SaveAsync(CreateTestRun("run-1", "tenant-1")).Wait();
_store.SaveAsync(CreateTestRun("run-2", "tenant-1")).Wait();
// Act
_store.Clear();
// Assert
Assert.Equal(0, _store.Count);
}
private static Run CreateTestRun(
string runId,
string tenantId,
string initiatedBy = "test-user",
string? cveId = null) => new()
{
RunId = runId,
TenantId = tenantId,
InitiatedBy = initiatedBy,
Title = $"Test Run {runId}",
Status = RunStatus.Created,
CreatedAt = DateTimeOffset.UtcNow,
Context = new RunContext
{
FocusedCveId = cveId
}
};
}

View File

@@ -0,0 +1,671 @@
// <copyright file="RunServiceIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Runs;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Integration;
/// <summary>
/// Integration tests for RunService covering full lifecycle scenarios.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
/// </summary>
[Trait("Category", "Integration")]
public sealed class RunServiceIntegrationTests : IAsyncLifetime
{
private readonly InMemoryRunStore _store = new();
private readonly FakeTimeProvider _timeProvider = new();
private readonly DeterministicGuidGenerator _guidGenerator = new();
private readonly RunService _service;
private readonly string _testTenantId = "test-tenant";
public RunServiceIntegrationTests()
{
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
_service = new RunService(
_store,
_timeProvider,
_guidGenerator,
NullLogger<RunService>.Instance);
}
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync()
{
_store.Clear();
return ValueTask.CompletedTask;
}
[Fact]
public async Task FullConversationToAttestationFlow_Succeeds()
{
// Phase 1: Create Run
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "alice@example.com",
Title = "CVE-2024-1234 Investigation",
Objective = "Determine if vulnerable code path is reachable",
Context = new RunContext
{
FocusedCveId = "CVE-2024-1234",
FocusedComponent = "pkg:npm/express@4.17.1",
Tags = ["critical", "production"]
}
});
run.Should().NotBeNull();
run.Status.Should().Be(RunStatus.Created);
run.Events.Should().HaveCount(1);
run.Events[0].Type.Should().Be(RunEventType.Created);
// Phase 2: Conversation (User turn -> Assistant turn -> User turn -> Assistant turn)
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var userTurn1 = await _service.AddUserTurnAsync(
_testTenantId, run.RunId,
"Can you analyze CVE-2024-1234 and tell me if we're affected?",
"alice@example.com",
[new EvidenceLink { Uri = "sbom:digest/sha256:abc123", Type = "sbom", Label = "Application SBOM" }]);
userTurn1.Type.Should().Be(RunEventType.UserTurn);
userTurn1.EvidenceLinks.Should().HaveCount(1);
// Run should be active now
var activeRun = await _service.GetAsync(_testTenantId, run.RunId);
activeRun!.Status.Should().Be(RunStatus.Active);
_timeProvider.Advance(TimeSpan.FromSeconds(2));
var assistantTurn1 = await _service.AddAssistantTurnAsync(
_testTenantId, run.RunId,
"Based on the SBOM analysis, express@4.17.1 is present. I found that the vulnerable function `parseQuery` is not called in your codebase.",
[new EvidenceLink { Uri = "reach:analysis/CVE-2024-1234", Type = "reachability" }]);
assistantTurn1.Type.Should().Be(RunEventType.AssistantTurn);
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddUserTurnAsync(
_testTenantId, run.RunId,
"Great, can you propose a VEX statement for this?",
"alice@example.com");
// Phase 3: Action Proposal
_timeProvider.Advance(TimeSpan.FromSeconds(2));
var proposalEvent = await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
{
ActionType = "vex:publish",
Subject = "CVE-2024-1234",
Rationale = "Vulnerable function parseQuery is not reachable in application code paths",
RequiresApproval = true,
Parameters = new Dictionary<string, string>
{
["vex_status"] = "not_affected",
["vex_justification"] = "vulnerable_code_not_in_execute_path"
}.ToImmutableDictionary(),
EvidenceLinks = [new EvidenceLink
{
Uri = "reach:analysis/CVE-2024-1234",
Type = "reachability",
Label = "Reachability Analysis"
}]
});
proposalEvent.Type.Should().Be(RunEventType.ActionProposed);
// Phase 4: Request Approval
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var pendingRun = await _service.RequestApprovalAsync(
_testTenantId, run.RunId,
["bob@example.com", "charlie@example.com"],
"Please review VEX statement for CVE-2024-1234");
pendingRun.Status.Should().Be(RunStatus.PendingApproval);
pendingRun.Approval.Should().NotBeNull();
pendingRun.Approval!.Approvers.Should().Contain("bob@example.com");
// Phase 5: Approval
_timeProvider.Advance(TimeSpan.FromMinutes(5));
var approvedRun = await _service.ApproveAsync(
_testTenantId, run.RunId,
approved: true,
approverId: "bob@example.com",
reason: "Reachability analysis is sound, VEX statement approved");
approvedRun.Status.Should().Be(RunStatus.Approved);
approvedRun.Approval!.Approved.Should().BeTrue();
approvedRun.Approval.ApprovedBy.Should().Be("bob@example.com");
// Phase 6: Execute Action
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var executedEvent = await _service.ExecuteActionAsync(
_testTenantId, run.RunId, proposalEvent.EventId);
executedEvent.Type.Should().Be(RunEventType.ActionExecuted);
// Phase 7: Add VEX Artifact
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var vexArtifact = new RunArtifact
{
ArtifactId = "vex-001",
Type = ArtifactType.VexStatement,
Name = "VEX Statement for CVE-2024-1234",
CreatedAt = _timeProvider.GetUtcNow(),
ContentDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
MediaType = "application/vnd.openvex+json",
StorageUri = "stellaops://artifacts/vex/vex-001.json"
};
var withArtifact = await _service.AddArtifactAsync(_testTenantId, run.RunId, vexArtifact);
withArtifact.Artifacts.Should().HaveCount(1);
withArtifact.Artifacts[0].ArtifactId.Should().Be("vex-001");
// Phase 8: Complete Run
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var completedRun = await _service.CompleteAsync(
_testTenantId, run.RunId,
"Investigation complete: CVE-2024-1234 not affected, VEX published");
completedRun.Status.Should().Be(RunStatus.Completed);
completedRun.CompletedAt.Should().NotBeNull();
completedRun.ContentDigest.Should().NotBeNullOrEmpty();
// Phase 9: Attest Run
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var attestedRun = await _service.AttestAsync(_testTenantId, run.RunId);
attestedRun.Attestation.Should().NotBeNull();
attestedRun.Attestation!.AttestationId.Should().StartWith("att-");
attestedRun.Attestation.ContentDigest.Should().Be(completedRun.ContentDigest);
// Verify final timeline
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
timeline.Length.Should().BeGreaterThanOrEqualTo(8);
// Verify event sequence is properly ordered
for (var i = 1; i < timeline.Length; i++)
{
timeline[i].SequenceNumber.Should().BeGreaterThan(timeline[i - 1].SequenceNumber);
timeline[i].Timestamp.Should().BeOnOrAfter(timeline[i - 1].Timestamp);
}
}
[Fact]
public async Task TimelinePersistence_EventsAreAppendOnly()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Timeline Test"
});
var initialEventCount = run.Events.Length;
// Act - Add multiple events
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 1", "user-1");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 1");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 2", "user-1");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 2");
// Assert
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
finalRun!.Events.Length.Should().Be(initialEventCount + 4);
// Events should have monotonically increasing sequence numbers
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
var sequenceNumbers = timeline.Select(e => e.SequenceNumber).ToList();
sequenceNumbers.Should().BeInAscendingOrder();
// Event timestamps should not go backwards
var timestamps = timeline.Select(e => e.Timestamp).ToList();
for (var i = 1; i < timestamps.Count; i++)
{
timestamps[i].Should().BeOnOrAfter(timestamps[i - 1]);
}
}
[Fact]
public async Task ArtifactStorageAndRetrieval_MultipleArtifactTypes()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Artifact Test"
});
var artifacts = new[]
{
new RunArtifact
{
ArtifactId = "vex-001",
Type = ArtifactType.VexStatement,
Name = "VEX Statement",
CreatedAt = _timeProvider.GetUtcNow(),
ContentDigest = "sha256:vex123",
MediaType = "application/vnd.openvex+json"
},
new RunArtifact
{
ArtifactId = "report-001",
Type = ArtifactType.Report,
Name = "Investigation Report",
CreatedAt = _timeProvider.GetUtcNow(),
ContentDigest = "sha256:report456",
MediaType = "application/pdf"
},
new RunArtifact
{
ArtifactId = "evidence-001",
Type = ArtifactType.EvidencePack,
Name = "Evidence Bundle",
CreatedAt = _timeProvider.GetUtcNow(),
ContentDigest = "sha256:evidence789",
MediaType = "application/zip"
}
};
// Act
foreach (var artifact in artifacts)
{
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddArtifactAsync(_testTenantId, run.RunId, artifact);
}
// Assert
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
finalRun!.Artifacts.Should().HaveCount(3);
// Verify all artifact types are present
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.VexStatement);
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.Report);
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.EvidencePack);
// Verify artifact details preserved
var vexArtifact = finalRun.Artifacts.Single(a => a.ArtifactId == "vex-001");
vexArtifact.ContentDigest.Should().Be("sha256:vex123");
vexArtifact.MediaType.Should().Be("application/vnd.openvex+json");
// Verify artifact events in timeline
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
timeline.Where(e => e.Type == RunEventType.ArtifactProduced).Should().HaveCount(3);
}
[Fact]
public async Task EvidencePackAttachment_TracksAllPacks()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Evidence Pack Test"
});
var evidencePacks = new[]
{
new EvidencePackReference
{
PackId = "pack-001",
Digest = "sha256:pack001hash",
AttachedAt = _timeProvider.GetUtcNow(),
PackType = "vulnerability-analysis"
},
new EvidencePackReference
{
PackId = "pack-002",
Digest = "sha256:pack002hash",
AttachedAt = _timeProvider.GetUtcNow(),
PackType = "reachability-evidence"
}
};
// Act
foreach (var pack in evidencePacks)
{
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AttachEvidencePackAsync(_testTenantId, run.RunId, pack);
}
// Assert
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
finalRun!.EvidencePacks.Should().HaveCount(2);
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-001");
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-002");
// Verify evidence events in timeline
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
timeline.Where(e => e.Type == RunEventType.EvidenceAttached).Should().HaveCount(2);
}
[Fact]
public async Task RunContentDigest_IsDeterministic()
{
// Create two identical runs
_guidGenerator.Reset();
var run1 = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Determinism Test"
});
await _service.AddUserTurnAsync(_testTenantId, run1.RunId, "Test message", "user-1");
await _service.AddAssistantTurnAsync(_testTenantId, run1.RunId, "Test response");
var completed1 = await _service.CompleteAsync(_testTenantId, run1.RunId);
// Reset state and create identical run
_store.Clear();
_guidGenerator.Reset();
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
var run2 = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Determinism Test"
});
await _service.AddUserTurnAsync(_testTenantId, run2.RunId, "Test message", "user-1");
await _service.AddAssistantTurnAsync(_testTenantId, run2.RunId, "Test response");
var completed2 = await _service.CompleteAsync(_testTenantId, run2.RunId);
// Assert - digests should be identical for identical content
completed1.ContentDigest.Should().Be(completed2.ContentDigest);
}
[Fact]
public async Task TenantIsolation_RunsAreIsolatedByTenant()
{
// Arrange - Create runs in different tenants
var tenant1Run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Tenant 1 Run"
});
var tenant2Run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-2",
InitiatedBy = "user-1",
Title = "Tenant 2 Run"
});
// Act & Assert - Cannot access other tenant's run
var crossTenantAccess = await _service.GetAsync("tenant-1", tenant2Run.RunId);
crossTenantAccess.Should().BeNull();
// Query only returns own tenant's runs
var tenant1Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-1" });
tenant1Query.Runs.Should().HaveCount(1);
tenant1Query.Runs[0].RunId.Should().Be(tenant1Run.RunId);
var tenant2Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-2" });
tenant2Query.Runs.Should().HaveCount(1);
tenant2Query.Runs[0].RunId.Should().Be(tenant2Run.RunId);
}
[Fact]
public async Task HandoffWorkflow_TransfersOwnershipCorrectly()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "alice@example.com",
Title = "Handoff Test"
});
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Starting investigation", "alice@example.com");
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Initial analysis complete");
// Act - Hand off to another user
_timeProvider.Advance(TimeSpan.FromMinutes(10));
var handedOff = await _service.HandOffAsync(
_testTenantId, run.RunId,
"bob@example.com",
"Please continue this investigation - I need to focus on another issue");
// Assert
handedOff.Metadata["current_owner"].Should().Be("bob@example.com");
// Verify handoff event in timeline
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
var handoffEvent = timeline.FirstOrDefault(e => e.Type == RunEventType.HandedOff);
handoffEvent.Should().NotBeNull();
handoffEvent!.Metadata["to_user"].Should().Be("bob@example.com");
// New owner can continue the run
_timeProvider.Advance(TimeSpan.FromSeconds(1));
var continuedEvent = await _service.AddUserTurnAsync(
_testTenantId, run.RunId,
"Continuing investigation from Alice",
"bob@example.com");
continuedEvent.Should().NotBeNull();
continuedEvent.ActorId.Should().Be("bob@example.com");
}
[Fact]
public async Task RejectionWorkflow_StopsRunOnRejection()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Rejection Test"
});
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question", "user-1");
await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
{
ActionType = "vex:publish",
RequiresApproval = true
});
await _service.RequestApprovalAsync(_testTenantId, run.RunId, ["approver-1"]);
// Act - Reject
var rejected = await _service.ApproveAsync(
_testTenantId, run.RunId,
approved: false,
approverId: "approver-1",
reason: "Insufficient evidence for not_affected status");
// Assert
rejected.Status.Should().Be(RunStatus.Rejected);
rejected.Approval!.Approved.Should().BeFalse();
rejected.CompletedAt.Should().NotBeNull();
// Cannot add more events to rejected run
await _service.Invoking(s => s.AddUserTurnAsync(
_testTenantId, run.RunId, "More questions", "user-1"))
.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task CancellationWorkflow_PreservesHistory()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Cancellation Test"
});
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 1", "user-1");
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Answer 1");
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 2", "user-1");
var eventCountBeforeCancel = (await _service.GetAsync(_testTenantId, run.RunId))!.Events.Length;
// Act
_timeProvider.Advance(TimeSpan.FromMinutes(30));
var cancelled = await _service.CancelAsync(
_testTenantId, run.RunId,
"Investigation no longer needed - issue resolved by upstream fix");
// Assert
cancelled.Status.Should().Be(RunStatus.Cancelled);
cancelled.CompletedAt.Should().NotBeNull();
// History is preserved
cancelled.Events.Length.Should().Be(eventCountBeforeCancel + 1);
cancelled.Events.Last().Type.Should().Be(RunEventType.Cancelled);
// Timeline still accessible
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
timeline.Length.Should().Be(eventCountBeforeCancel + 1);
}
[Fact]
public async Task AttestAsync_FailsForNonCompletedRun()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Attest Validation Test"
});
// Act & Assert - Cannot attest non-completed run
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not completed*");
}
[Fact]
public async Task AttestAsync_FailsForAlreadyAttestedRun()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Double Attest Test"
});
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Q", "user-1");
await _service.CompleteAsync(_testTenantId, run.RunId);
await _service.AttestAsync(_testTenantId, run.RunId);
// Act & Assert - Cannot attest twice
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already attested*");
}
[Fact]
public async Task QueryAsync_PaginationWorks()
{
// Arrange - Create 10 runs
for (var i = 0; i < 10; i++)
{
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = $"Run {i + 1}"
});
}
// Act - Page 1
var page1 = await _service.QueryAsync(new RunQuery
{
TenantId = _testTenantId,
Skip = 0,
Take = 3
});
// Act - Page 2
var page2 = await _service.QueryAsync(new RunQuery
{
TenantId = _testTenantId,
Skip = 3,
Take = 3
});
// Assert
page1.TotalCount.Should().Be(10);
page1.Runs.Should().HaveCount(3);
page1.HasMore.Should().BeTrue();
page2.Runs.Should().HaveCount(3);
page2.HasMore.Should().BeTrue();
// No overlap between pages
page1.Runs.Select(r => r.RunId)
.Should().NotIntersectWith(page2.Runs.Select(r => r.RunId));
}
[Fact]
public async Task GetTimelineAsync_PaginationWorks()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = _testTenantId,
InitiatedBy = "user-1",
Title = "Timeline Pagination Test"
});
// Add 20 events
for (var i = 0; i < 10; i++)
{
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddUserTurnAsync(_testTenantId, run.RunId, $"Message {i}", "user-1");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, $"Response {i}");
}
// Act
var page1 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 0, take: 5);
var page2 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 5, take: 5);
var page3 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 10, take: 5);
// Assert
page1.Should().HaveCount(5);
page2.Should().HaveCount(5);
page3.Should().HaveCount(5);
// Verify sequence continuity
page1.Last().SequenceNumber.Should().BeLessThan(page2.First().SequenceNumber);
page2.Last().SequenceNumber.Should().BeLessThan(page3.First().SequenceNumber);
}
/// <summary>
/// Deterministic GUID generator for testing.
/// </summary>
private sealed class DeterministicGuidGenerator : IGuidGenerator
{
private int _counter;
public Guid NewGuid()
{
var bytes = new byte[16];
BitConverter.GetBytes(_counter++).CopyTo(bytes, 0);
return new Guid(bytes);
}
public void Reset() => _counter = 0;
}
}

View File

@@ -0,0 +1,464 @@
// <copyright file="RunServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Runs;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
/// <summary>
/// Unit tests for <see cref="RunService"/>.
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
/// </summary>
[Trait("Category", "Unit")]
public sealed class RunServiceTests
{
private readonly InMemoryRunStore _store = new();
private readonly FakeTimeProvider _timeProvider = new();
private readonly NullLogger<RunService> _logger = NullLogger<RunService>.Instance;
private readonly RunService _service;
public RunServiceTests()
{
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
_service = new RunService(_store, _timeProvider, new DefaultGuidGenerator(), _logger);
}
[Fact]
public async Task CreateAsync_GeneratesRunWithCorrectProperties()
{
// Arrange
var request = new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run",
Objective = "Investigate CVE-2024-1234"
};
// Act
var run = await _service.CreateAsync(request);
// Assert
Assert.NotNull(run);
Assert.NotEmpty(run.RunId);
Assert.Equal("tenant-1", run.TenantId);
Assert.Equal("user-1", run.InitiatedBy);
Assert.Equal("Test Run", run.Title);
Assert.Equal("Investigate CVE-2024-1234", run.Objective);
Assert.Equal(RunStatus.Created, run.Status);
Assert.Equal(_timeProvider.GetUtcNow(), run.CreatedAt);
}
[Fact]
public async Task CreateAsync_WithContext_StoresContextCorrectly()
{
// Arrange
var request = new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "CVE Investigation",
Context = new RunContext
{
FocusedCveId = "CVE-2024-5678",
FocusedComponent = "pkg:npm/express@4.17.1",
Tags = ["critical", "urgent"]
}
};
// Act
var run = await _service.CreateAsync(request);
// Assert
Assert.Equal("CVE-2024-5678", run.Context.FocusedCveId);
Assert.Equal("pkg:npm/express@4.17.1", run.Context.FocusedComponent);
Assert.Contains("critical", run.Context.Tags);
Assert.Contains("urgent", run.Context.Tags);
}
[Fact]
public async Task GetAsync_ExistingRun_ReturnsRun()
{
// Arrange
var request = new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
};
var created = await _service.CreateAsync(request);
// Act
var retrieved = await _service.GetAsync("tenant-1", created.RunId);
// Assert
Assert.NotNull(retrieved);
Assert.Equal(created.RunId, retrieved.RunId);
}
[Fact]
public async Task GetAsync_NonExistentRun_ReturnsNull()
{
// Act
var retrieved = await _service.GetAsync("tenant-1", "non-existent");
// Assert
Assert.Null(retrieved);
}
[Fact]
public async Task AddUserTurnAsync_AddsEventAndActivatesRun()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
// Act
var evt = await _service.AddUserTurnAsync(
"tenant-1", run.RunId, "What vulnerabilities affect this component?", "user-1");
// Assert
Assert.NotNull(evt);
Assert.Equal(RunEventType.UserTurn, evt.Type);
Assert.Equal("user-1", evt.ActorId);
var turnContent = Assert.IsType<TurnContent>(evt.Content);
Assert.Contains("vulnerabilities", turnContent.Message);
var updated = await _service.GetAsync("tenant-1", run.RunId);
Assert.Equal(RunStatus.Active, updated!.Status);
}
[Fact]
public async Task AddAssistantTurnAsync_AddsEventCorrectly()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question?", "user-1");
// Act
var evt = await _service.AddAssistantTurnAsync(
"tenant-1", run.RunId, "Here is my analysis of the vulnerability...");
// Assert
Assert.NotNull(evt);
Assert.Equal(RunEventType.AssistantTurn, evt.Type);
Assert.Equal("assistant", evt.ActorId);
var assistantContent = Assert.IsType<TurnContent>(evt.Content);
Assert.Contains("analysis", assistantContent.Message);
}
[Fact]
public async Task ProposeActionAsync_AddsActionProposal()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Fix this CVE", "user-1");
// Act
var evt = await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
{
ActionType = "vex:publish",
Subject = "CVE-2024-1234",
Rationale = "Component is not affected due to usage context",
RequiresApproval = true
});
// Assert
Assert.NotNull(evt);
Assert.Equal(RunEventType.ActionProposed, evt.Type);
var actionContent = Assert.IsType<ActionProposedContent>(evt.Content);
Assert.Equal("vex:publish", actionContent.ActionType);
}
[Fact]
public async Task RequestApprovalAsync_ChangesStatusToPendingApproval()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
{
ActionType = "vex:publish",
RequiresApproval = true
});
// Act
var updated = await _service.RequestApprovalAsync(
"tenant-1", run.RunId, ["approver-1", "approver-2"], "Please review VEX decision");
// Assert
Assert.Equal(RunStatus.PendingApproval, updated.Status);
Assert.NotNull(updated.Approval);
Assert.True(updated.Approval.Required);
Assert.Contains("approver-1", updated.Approval.Approvers);
Assert.Contains("approver-2", updated.Approval.Approvers);
}
[Fact]
public async Task ApproveAsync_Approved_ChangesStatusToApproved()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
{
ActionType = "vex:publish",
RequiresApproval = true
});
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
// Act
var updated = await _service.ApproveAsync(
"tenant-1", run.RunId, approved: true, "approver-1", "Looks good");
// Assert
Assert.Equal(RunStatus.Approved, updated.Status);
Assert.NotNull(updated.Approval);
Assert.True(updated.Approval.Approved);
Assert.Equal("approver-1", updated.Approval.ApprovedBy);
}
[Fact]
public async Task ApproveAsync_Rejected_ChangesStatusToRejected()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
{
ActionType = "vex:publish",
RequiresApproval = true
});
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
// Act
var updated = await _service.ApproveAsync(
"tenant-1", run.RunId, approved: false, "approver-1", "Needs more justification");
// Assert
Assert.Equal(RunStatus.Rejected, updated.Status);
Assert.False(updated.Approval!.Approved);
}
[Fact]
public async Task CompleteAsync_SetsCompletedStatusAndTimestamp()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer");
// Act
var completed = await _service.CompleteAsync("tenant-1", run.RunId, "Investigation complete");
// Assert
Assert.Equal(RunStatus.Completed, completed.Status);
Assert.NotNull(completed.CompletedAt);
Assert.Equal(_timeProvider.GetUtcNow(), completed.CompletedAt);
}
[Fact]
public async Task CancelAsync_SetsCancelledStatus()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
// Act
var cancelled = await _service.CancelAsync("tenant-1", run.RunId, "No longer needed");
// Assert
Assert.Equal(RunStatus.Cancelled, cancelled.Status);
}
[Fact]
public async Task AddArtifactAsync_AddsArtifactToRun()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
var artifact = new RunArtifact
{
ArtifactId = "artifact-1",
Type = ArtifactType.VexStatement,
Name = "VEX for CVE-2024-1234",
CreatedAt = _timeProvider.GetUtcNow(),
ContentDigest = "sha256:abc123",
MediaType = "application/vnd.openvex+json"
};
// Act
var updated = await _service.AddArtifactAsync("tenant-1", run.RunId, artifact);
// Assert
Assert.Single(updated.Artifacts);
Assert.Equal("artifact-1", updated.Artifacts[0].ArtifactId);
Assert.Equal(ArtifactType.VexStatement, updated.Artifacts[0].Type);
}
[Fact]
public async Task QueryAsync_FiltersCorrectly()
{
// Arrange
await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Run 1",
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
});
await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-2",
Title = "Run 2",
Context = new RunContext { FocusedCveId = "CVE-2024-2222" }
});
await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Run 3",
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
});
// Act
var result = await _service.QueryAsync(new RunQuery
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
CveId = "CVE-2024-1111"
});
// Assert
Assert.Equal(2, result.TotalCount);
Assert.All(result.Runs, r =>
{
Assert.Equal("user-1", r.InitiatedBy);
Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId);
});
}
[Fact]
public async Task GetTimelineAsync_ReturnsEventsInOrder()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 1", "user-1");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer 1");
_timeProvider.Advance(TimeSpan.FromSeconds(1));
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 2", "user-1");
// Act
var timeline = await _service.GetTimelineAsync("tenant-1", run.RunId);
// Assert
Assert.Equal(3, timeline.Length);
Assert.Equal(RunEventType.UserTurn, timeline[0].Type);
Assert.Equal(RunEventType.AssistantTurn, timeline[1].Type);
Assert.Equal(RunEventType.UserTurn, timeline[2].Type);
// Verify sequence numbers are ordered
Assert.True(timeline[0].SequenceNumber < timeline[1].SequenceNumber);
Assert.True(timeline[1].SequenceNumber < timeline[2].SequenceNumber);
}
[Fact]
public async Task HandOffAsync_TransfersOwnership()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
// Act
var updated = await _service.HandOffAsync("tenant-1", run.RunId, "user-2", "Please continue");
// Assert
Assert.Equal("user-2", updated.Metadata["current_owner"]);
}
[Fact]
public async Task AddEventAsync_NonExistentRun_ThrowsInvalidOperation()
{
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.AddUserTurnAsync("tenant-1", "non-existent", "Message", "user-1"));
}
[Fact]
public async Task CompleteAsync_AlreadyCompleted_ThrowsInvalidOperation()
{
// Arrange
var run = await _service.CreateAsync(new CreateRunRequest
{
TenantId = "tenant-1",
InitiatedBy = "user-1",
Title = "Test Run"
});
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Q", "user-1");
await _service.CompleteAsync("tenant-1", run.RunId);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CompleteAsync("tenant-1", run.RunId));
}
}

View File

@@ -13,6 +13,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />