more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View File

@@ -0,0 +1,559 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Collections.Immutable;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Storage;
namespace StellaOps.OpsMemory.WebService.Endpoints;
/// <summary>
/// OpsMemory API endpoints for decision recording and playbook suggestions.
/// Sprint: SPRINT_20260107_006_004 Task OM-006
/// </summary>
public static class OpsMemoryEndpoints
{
/// <summary>
/// Maps OpsMemory endpoints.
/// </summary>
public static void MapOpsMemoryEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/opsmemory")
.WithTags("OpsMemory");
group.MapPost("/decisions", RecordDecisionAsync)
.WithName("RecordDecision")
.WithDescription("Record a security decision for future playbook learning");
group.MapGet("/decisions/{memoryId}", GetDecisionAsync)
.WithName("GetDecision")
.WithDescription("Get a specific decision by ID");
group.MapPost("/decisions/{memoryId}/outcome", RecordOutcomeAsync)
.WithName("RecordOutcome")
.WithDescription("Record the outcome of a previous decision");
group.MapGet("/suggestions", GetSuggestionsAsync)
.WithName("GetPlaybookSuggestions")
.WithDescription("Get playbook suggestions for a given situation");
group.MapGet("/decisions", QueryDecisionsAsync)
.WithName("QueryDecisions")
.WithDescription("Query past decisions with filters");
group.MapGet("/stats", GetStatsAsync)
.WithName("GetOpsMemoryStats")
.WithDescription("Get decision statistics for a tenant");
}
/// <summary>
/// Record a new security decision.
/// </summary>
private static async Task<Results<Created<RecordDecisionResponse>, BadRequest<ProblemDetails>>> RecordDecisionAsync(
RecordDecisionRequest request,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "TenantId is required"
});
}
if (!Enum.TryParse<DecisionAction>(request.Action, ignoreCase: true, out var action))
{
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = $"Invalid action: {request.Action}. Valid values: {string.Join(", ", Enum.GetNames<DecisionAction>())}"
});
}
var record = new OpsMemoryRecord
{
MemoryId = Guid.NewGuid().ToString("N"),
TenantId = request.TenantId,
RecordedAt = DateTimeOffset.UtcNow,
Situation = new SituationContext
{
CveId = request.CveId,
Component = request.ComponentPurl,
Severity = request.Severity,
Reachability = ParseReachability(request.Reachability),
EpssScore = request.EpssScore,
CvssScore = request.CvssScore,
ContextTags = request.ContextTags?.ToImmutableArray() ?? ImmutableArray<string>.Empty
},
Decision = new DecisionRecord
{
Action = action,
Rationale = request.Rationale ?? string.Empty,
DecidedBy = request.DecidedBy ?? "unknown",
DecidedAt = DateTimeOffset.UtcNow,
PolicyReference = request.PolicyReference,
Mitigation = !string.IsNullOrEmpty(request.MitigationType)
? new MitigationDetails
{
Type = request.MitigationType,
Description = request.MitigationDetails ?? string.Empty
}
: null
}
};
var saved = await store.RecordDecisionAsync(record, cancellationToken);
return TypedResults.Created(
$"/api/v1/opsmemory/decisions/{saved.MemoryId}",
new RecordDecisionResponse
{
MemoryId = saved.MemoryId,
RecordedAt = saved.RecordedAt
});
}
/// <summary>
/// Get a specific decision.
/// </summary>
private static async Task<Results<Ok<DecisionDetailsResponse>, NotFound>> GetDecisionAsync(
string memoryId,
string tenantId,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
var record = await store.GetByIdAsync(memoryId, tenantId, cancellationToken);
if (record == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDetailsResponse(record));
}
/// <summary>
/// Record the outcome of a decision.
/// </summary>
private static async Task<Results<Ok<OutcomeRecordedResponse>, NotFound, BadRequest<ProblemDetails>>> RecordOutcomeAsync(
string memoryId,
string tenantId,
RecordOutcomeRequest request,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<OutcomeStatus>(request.Status, ignoreCase: true, out var status))
{
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = $"Invalid status: {request.Status}. Valid values: {string.Join(", ", Enum.GetNames<OutcomeStatus>())}"
});
}
var outcome = new OutcomeRecord
{
Status = status,
ResolutionTime = request.ResolutionTimeMinutes.HasValue
? TimeSpan.FromMinutes(request.ResolutionTimeMinutes.Value)
: null,
ActualImpact = request.ActualImpact,
LessonsLearned = request.LessonsLearned,
RecordedBy = request.RecordedBy ?? "unknown",
RecordedAt = DateTimeOffset.UtcNow
};
var updated = await store.RecordOutcomeAsync(memoryId, tenantId, outcome, cancellationToken);
if (updated == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new OutcomeRecordedResponse
{
MemoryId = memoryId,
Status = status.ToString(),
RecordedAt = outcome.RecordedAt
});
}
/// <summary>
/// Get playbook suggestions for a situation.
/// </summary>
private static async Task<Ok<PlaybookSuggestionsResponse>> GetSuggestionsAsync(
string tenantId,
PlaybookSuggestionService suggestionService,
string? cveId,
string? componentPurl,
string? severity,
string? reachability,
double? epssScore,
double? cvssScore,
int? limit,
CancellationToken cancellationToken)
{
var request = new PlaybookSuggestionRequest
{
TenantId = tenantId,
Situation = new SituationContext
{
CveId = cveId,
Component = componentPurl,
Severity = severity,
Reachability = ParseReachability(reachability),
EpssScore = epssScore,
CvssScore = cvssScore
},
MaxSuggestions = limit ?? 3
};
var result = await suggestionService.GetSuggestionsAsync(request, cancellationToken);
return TypedResults.Ok(new PlaybookSuggestionsResponse
{
Suggestions = result.Suggestions.Select(s => new PlaybookSuggestionDto
{
SuggestedAction = s.Action.ToString(),
Confidence = s.Confidence,
Rationale = s.Rationale,
SuccessRate = s.SuccessRate,
SimilarDecisionCount = s.SimilarDecisionCount,
AverageResolutionTimeMinutes = s.AverageResolutionTime?.TotalMinutes,
Evidence = s.Evidence.Select(e => new PlaybookEvidenceDto
{
MemoryId = e.MemoryId,
Similarity = e.Similarity,
Action = e.Action.ToString(),
Outcome = e.Outcome.ToString(),
DecidedAt = e.DecidedAt,
CveId = e.Cve,
ComponentPurl = e.Component
}).ToList(),
MatchingFactors = s.MatchingFactors.ToList()
}).ToList(),
AnalyzedRecords = result.AnalyzedRecords,
TopSimilarity = result.TopSimilarity
});
}
/// <summary>
/// Query past decisions.
/// </summary>
private static async Task<Ok<QueryDecisionsResponse>> QueryDecisionsAsync(
string tenantId,
IOpsMemoryStore store,
string? cveId,
string? componentPrefix,
string? action,
string? outcomeStatus,
int? pageSize,
string? cursor,
CancellationToken cancellationToken)
{
DecisionAction? actionFilter = null;
if (!string.IsNullOrEmpty(action) && Enum.TryParse<DecisionAction>(action, ignoreCase: true, out var a))
{
actionFilter = a;
}
OutcomeStatus? statusFilter = null;
if (!string.IsNullOrEmpty(outcomeStatus) && Enum.TryParse<OutcomeStatus>(outcomeStatus, ignoreCase: true, out var s))
{
statusFilter = s;
}
var query = new OpsMemoryQuery
{
TenantId = tenantId,
CveId = cveId,
ComponentPrefix = componentPrefix,
Action = actionFilter,
OutcomeStatus = statusFilter,
PageSize = pageSize ?? 20,
Cursor = cursor
};
var result = await store.QueryAsync(query, cancellationToken);
return TypedResults.Ok(new QueryDecisionsResponse
{
Decisions = result.Items.Select(MapToSummary).ToList(),
TotalCount = result.TotalCount,
NextCursor = result.NextCursor,
HasMore = result.HasMore
});
}
/// <summary>
/// Get statistics for a tenant.
/// </summary>
private static async Task<Ok<OpsMemoryStatsResponse>> GetStatsAsync(
string tenantId,
IOpsMemoryStore store,
CancellationToken cancellationToken)
{
var stats = await store.GetStatsAsync(tenantId, null, cancellationToken);
return TypedResults.Ok(new OpsMemoryStatsResponse
{
TenantId = tenantId,
TotalDecisions = stats.TotalDecisions,
DecisionsWithOutcomes = stats.DecisionsWithOutcomes,
SuccessRate = stats.SuccessRate
});
}
private static ReachabilityStatus ParseReachability(string? value)
{
if (string.IsNullOrEmpty(value))
{
return ReachabilityStatus.Unknown;
}
return Enum.TryParse<ReachabilityStatus>(value, ignoreCase: true, out var result)
? result
: ReachabilityStatus.Unknown;
}
private static DecisionDetailsResponse MapToDetailsResponse(OpsMemoryRecord record)
{
return new DecisionDetailsResponse
{
MemoryId = record.MemoryId,
TenantId = record.TenantId,
RecordedAt = record.RecordedAt,
Situation = new SituationDto
{
CveId = record.Situation.CveId,
Component = record.Situation.Component,
ComponentName = record.Situation.ComponentName,
Severity = record.Situation.Severity,
Reachability = record.Situation.Reachability.ToString(),
EpssScore = record.Situation.EpssScore,
CvssScore = record.Situation.CvssScore,
IsKev = record.Situation.IsKev,
ContextTags = record.Situation.ContextTags.ToList()
},
Decision = new DecisionDto
{
Action = record.Decision.Action.ToString(),
Rationale = record.Decision.Rationale,
DecidedBy = record.Decision.DecidedBy,
DecidedAt = record.Decision.DecidedAt,
PolicyReference = record.Decision.PolicyReference,
VexStatementId = record.Decision.VexStatementId,
Mitigation = record.Decision.Mitigation != null
? new MitigationDto
{
Type = record.Decision.Mitigation.Type,
Description = record.Decision.Mitigation.Description,
Effectiveness = record.Decision.Mitigation.Effectiveness,
ExpiresAt = record.Decision.Mitigation.ExpiresAt
}
: null
},
Outcome = record.Outcome != null
? new OutcomeDto
{
Status = record.Outcome.Status.ToString(),
ResolutionTimeMinutes = record.Outcome.ResolutionTime?.TotalMinutes,
ActualImpact = record.Outcome.ActualImpact,
LessonsLearned = record.Outcome.LessonsLearned,
RecordedBy = record.Outcome.RecordedBy,
RecordedAt = record.Outcome.RecordedAt,
WouldRepeat = record.Outcome.WouldRepeat,
AlternativeActions = record.Outcome.AlternativeActions
}
: null
};
}
private static DecisionSummaryDto MapToSummary(OpsMemoryRecord record)
{
return new DecisionSummaryDto
{
MemoryId = record.MemoryId,
RecordedAt = record.RecordedAt,
CveId = record.Situation.CveId,
Component = record.Situation.Component,
Action = record.Decision.Action.ToString(),
OutcomeStatus = record.Outcome?.Status.ToString(),
DecidedBy = record.Decision.DecidedBy
};
}
}
#region Request/Response DTOs
/// <summary>Request to record a decision.</summary>
public sealed record RecordDecisionRequest
{
public required string TenantId { get; init; }
public string? CveId { get; init; }
public string? ComponentPurl { get; init; }
public string? Severity { get; init; }
public string? Reachability { get; init; }
public double? EpssScore { get; init; }
public double? CvssScore { get; init; }
public List<string>? ContextTags { get; init; }
public required string Action { get; init; }
public string? Rationale { get; init; }
public string? DecidedBy { get; init; }
public string? PolicyReference { get; init; }
public string? MitigationType { get; init; }
public string? MitigationDetails { get; init; }
}
/// <summary>Response after recording a decision.</summary>
public sealed record RecordDecisionResponse
{
public required string MemoryId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
/// <summary>Request to record an outcome.</summary>
public sealed record RecordOutcomeRequest
{
public required string Status { get; init; }
public int? ResolutionTimeMinutes { get; init; }
public string? ActualImpact { get; init; }
public string? LessonsLearned { get; init; }
public string? RecordedBy { get; init; }
}
/// <summary>Response after recording an outcome.</summary>
public sealed record OutcomeRecordedResponse
{
public required string MemoryId { get; init; }
public required string Status { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
/// <summary>Detailed decision information.</summary>
public sealed record DecisionDetailsResponse
{
public required string MemoryId { get; init; }
public required string TenantId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public required SituationDto Situation { get; init; }
public required DecisionDto Decision { get; init; }
public OutcomeDto? Outcome { get; init; }
}
/// <summary>Situation context DTO.</summary>
public sealed record SituationDto
{
public string? CveId { get; init; }
public string? Component { get; init; }
public string? ComponentName { get; init; }
public string? Severity { get; init; }
public string? Reachability { get; init; }
public double? EpssScore { get; init; }
public double? CvssScore { get; init; }
public bool IsKev { get; init; }
public List<string>? ContextTags { get; init; }
}
/// <summary>Decision DTO.</summary>
public sealed record DecisionDto
{
public required string Action { get; init; }
public required string Rationale { get; init; }
public required string DecidedBy { get; init; }
public DateTimeOffset DecidedAt { get; init; }
public string? PolicyReference { get; init; }
public string? VexStatementId { get; init; }
public MitigationDto? Mitigation { get; init; }
}
/// <summary>Mitigation DTO.</summary>
public sealed record MitigationDto
{
public required string Type { get; init; }
public required string Description { get; init; }
public double? Effectiveness { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>Outcome DTO.</summary>
public sealed record OutcomeDto
{
public required string Status { get; init; }
public double? ResolutionTimeMinutes { get; init; }
public string? ActualImpact { get; init; }
public string? LessonsLearned { get; init; }
public required string RecordedBy { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public bool? WouldRepeat { get; init; }
public string? AlternativeActions { get; init; }
}
/// <summary>Playbook suggestions response.</summary>
public sealed record PlaybookSuggestionsResponse
{
public required List<PlaybookSuggestionDto> Suggestions { get; init; }
public int AnalyzedRecords { get; init; }
public double? TopSimilarity { get; init; }
}
/// <summary>A single playbook suggestion.</summary>
public sealed record PlaybookSuggestionDto
{
public required string SuggestedAction { get; init; }
public double Confidence { get; init; }
public required string Rationale { get; init; }
public double SuccessRate { get; init; }
public int SimilarDecisionCount { get; init; }
public double? AverageResolutionTimeMinutes { get; init; }
public required List<PlaybookEvidenceDto> Evidence { get; init; }
public List<string>? MatchingFactors { get; init; }
}
/// <summary>Evidence for a suggestion.</summary>
public sealed record PlaybookEvidenceDto
{
public required string MemoryId { get; init; }
public double Similarity { get; init; }
public required string Action { get; init; }
public required string Outcome { get; init; }
public DateTimeOffset DecidedAt { get; init; }
public string? CveId { get; init; }
public string? ComponentPurl { get; init; }
}
/// <summary>Query decisions response.</summary>
public sealed record QueryDecisionsResponse
{
public required List<DecisionSummaryDto> Decisions { get; init; }
public int? TotalCount { get; init; }
public string? NextCursor { get; init; }
public bool HasMore { get; init; }
}
/// <summary>Decision summary for lists.</summary>
public sealed record DecisionSummaryDto
{
public required string MemoryId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public string? CveId { get; init; }
public string? Component { get; init; }
public required string Action { get; init; }
public string? OutcomeStatus { get; init; }
public string? DecidedBy { get; init; }
}
/// <summary>Statistics response.</summary>
public sealed record OpsMemoryStatsResponse
{
public required string TenantId { get; init; }
public int TotalDecisions { get; init; }
public int DecisionsWithOutcomes { get; init; }
public double SuccessRate { get; init; }
}
#endregion

View File

@@ -0,0 +1,49 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Npgsql;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using StellaOps.OpsMemory.WebService.Endpoints;
var builder = WebApplication.CreateBuilder(args);
// Add PostgreSQL data source
var connectionString = builder.Configuration.GetConnectionString("OpsMemory")
?? "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
// Add OpsMemory services
builder.Services.AddSingleton<IOpsMemoryStore, PostgresOpsMemoryStore>();
builder.Services.AddSingleton<SimilarityVectorGenerator>();
builder.Services.AddSingleton<PlaybookSuggestionService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "StellaOps OpsMemory API",
Version = "v1",
Description = "Decision ledger and playbook suggestions API for security operations learning"
});
});
builder.Services.AddHealthChecks();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Map endpoints
app.MapOpsMemoryEndpoints();
app.MapHealthChecks("/health");
app.Run();

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.OpsMemory.WebService</RootNamespace>
<Description>StellaOps OpsMemory Service - Decision ledger and playbook suggestions API</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Npgsql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,300 @@
// <copyright file="OpsMemoryRecord.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.OpsMemory.Models;
/// <summary>
/// A structured record of a security decision and its outcome for playbook learning.
/// Sprint: SPRINT_20260107_006_004 Task OM-001
/// </summary>
public sealed record OpsMemoryRecord
{
/// <summary>
/// Gets the unique memory record identifier.
/// </summary>
public required string MemoryId { get; init; }
/// <summary>
/// Gets the tenant identifier for multi-tenancy isolation.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the timestamp when this record was created.
/// </summary>
public required DateTimeOffset RecordedAt { get; init; }
/// <summary>
/// Gets the situation context at the time of decision.
/// </summary>
public required SituationContext Situation { get; init; }
/// <summary>
/// Gets the decision that was made.
/// </summary>
public required DecisionRecord Decision { get; init; }
/// <summary>
/// Gets the outcome of the decision (if recorded).
/// </summary>
public OutcomeRecord? Outcome { get; init; }
/// <summary>
/// Gets the similarity vector for finding related decisions.
/// </summary>
public ImmutableArray<float> SimilarityVector { get; init; } = ImmutableArray<float>.Empty;
/// <summary>
/// Gets whether this record has an outcome recorded.
/// </summary>
public bool HasOutcome => Outcome is not null;
/// <summary>
/// Gets whether this decision was successful.
/// </summary>
public bool WasSuccessful => Outcome?.Status == OutcomeStatus.Success;
}
/// <summary>
/// The security context at the time a decision was made.
/// </summary>
public sealed record SituationContext
{
/// <summary>
/// Gets the CVE identifier (if applicable).
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Gets the component PURL.
/// </summary>
public string? Component { get; init; }
/// <summary>
/// Gets the component name (for display).
/// </summary>
public string? ComponentName { get; init; }
/// <summary>
/// Gets the component version.
/// </summary>
public string? ComponentVersion { get; init; }
/// <summary>
/// Gets the severity level.
/// </summary>
public string? Severity { get; init; }
/// <summary>
/// Gets the CVSS score (0-10).
/// </summary>
public double? CvssScore { get; init; }
/// <summary>
/// Gets the reachability status.
/// </summary>
public ReachabilityStatus Reachability { get; init; }
/// <summary>
/// Gets the EPSS score (0-1).
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// Gets the KEV (Known Exploited Vulnerability) status.
/// </summary>
public bool IsKev { get; init; }
/// <summary>
/// Gets the context tags (environment, service, team, etc.).
/// </summary>
public ImmutableArray<string> ContextTags { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets additional context fields.
/// </summary>
public ImmutableDictionary<string, string> AdditionalContext { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Reachability status for a vulnerability.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Not analyzed.</summary>
Unknown,
/// <summary>Confirmed reachable from entry point.</summary>
Reachable,
/// <summary>Analyzed and determined not reachable.</summary>
NotReachable,
/// <summary>Potentially reachable (inconclusive analysis).</summary>
Potential
}
/// <summary>
/// A security decision that was made.
/// </summary>
public sealed record DecisionRecord
{
/// <summary>
/// Gets the action taken.
/// </summary>
public required DecisionAction Action { get; init; }
/// <summary>
/// Gets the human-readable rationale.
/// </summary>
public required string Rationale { get; init; }
/// <summary>
/// Gets the identity of the decision maker.
/// </summary>
public required string DecidedBy { get; init; }
/// <summary>
/// Gets the timestamp of the decision.
/// </summary>
public required DateTimeOffset DecidedAt { get; init; }
/// <summary>
/// Gets the policy reference that guided this decision.
/// </summary>
public string? PolicyReference { get; init; }
/// <summary>
/// Gets the VEX statement ID (if one was created).
/// </summary>
public string? VexStatementId { get; init; }
/// <summary>
/// Gets any mitigation details.
/// </summary>
public MitigationDetails? Mitigation { get; init; }
}
/// <summary>
/// Actions that can be taken for a security finding.
/// </summary>
public enum DecisionAction
{
/// <summary>Accept the risk (no action).</summary>
Accept,
/// <summary>Remediate (upgrade/patch).</summary>
Remediate,
/// <summary>Quarantine/isolate the component.</summary>
Quarantine,
/// <summary>Apply mitigation (WAF, config, etc.).</summary>
Mitigate,
/// <summary>Defer for later review.</summary>
Defer,
/// <summary>Escalate to security team.</summary>
Escalate,
/// <summary>False positive - not applicable.</summary>
FalsePositive
}
/// <summary>
/// Details about a mitigation applied.
/// </summary>
public sealed record MitigationDetails
{
/// <summary>
/// Gets the type of mitigation.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Gets the description of the mitigation.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Gets the mitigation effectiveness (0-1).
/// </summary>
public double? Effectiveness { get; init; }
/// <summary>
/// Gets the expiration date for the mitigation.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// The outcome of a security decision.
/// </summary>
public sealed record OutcomeRecord
{
/// <summary>
/// Gets the outcome status.
/// </summary>
public required OutcomeStatus Status { get; init; }
/// <summary>
/// Gets the time to resolution.
/// </summary>
public TimeSpan? ResolutionTime { get; init; }
/// <summary>
/// Gets the actual impact experienced.
/// </summary>
public string? ActualImpact { get; init; }
/// <summary>
/// Gets lessons learned from this decision.
/// </summary>
public string? LessonsLearned { get; init; }
/// <summary>
/// Gets who recorded the outcome.
/// </summary>
public required string RecordedBy { get; init; }
/// <summary>
/// Gets when the outcome was recorded.
/// </summary>
public required DateTimeOffset RecordedAt { get; init; }
/// <summary>
/// Gets whether the original decision would be made again.
/// </summary>
public bool? WouldRepeat { get; init; }
/// <summary>
/// Gets alternative actions that might have been better.
/// </summary>
public string? AlternativeActions { get; init; }
}
/// <summary>
/// Outcome status for a decision.
/// </summary>
public enum OutcomeStatus
{
/// <summary>Decision led to successful resolution.</summary>
Success,
/// <summary>Decision led to partial resolution.</summary>
PartialSuccess,
/// <summary>Decision was ineffective.</summary>
Ineffective,
/// <summary>Decision led to negative consequences.</summary>
NegativeOutcome,
/// <summary>Outcome is still pending.</summary>
Pending
}

View File

@@ -0,0 +1,399 @@
// <copyright file="PlaybookSuggestionService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
namespace StellaOps.OpsMemory.Playbook;
/// <summary>
/// Service for generating playbook suggestions based on past decisions.
/// Sprint: SPRINT_20260107_006_004 Task OM-005
/// </summary>
public sealed class PlaybookSuggestionService
{
private readonly IOpsMemoryStore _store;
private readonly SimilarityVectorGenerator _vectorGenerator;
private readonly ILogger<PlaybookSuggestionService> _logger;
private const int DefaultTopK = 10;
private const int MaxSuggestions = 3;
private const double MinConfidence = 0.5;
/// <summary>
/// Initializes a new instance of the <see cref="PlaybookSuggestionService"/> class.
/// </summary>
public PlaybookSuggestionService(
IOpsMemoryStore store,
SimilarityVectorGenerator vectorGenerator,
ILogger<PlaybookSuggestionService> logger)
{
_store = store;
_vectorGenerator = vectorGenerator;
_logger = logger;
}
/// <summary>
/// Gets playbook suggestions for a given situation.
/// </summary>
/// <param name="request">The suggestion request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Playbook suggestions ordered by confidence.</returns>
public async Task<PlaybookSuggestionResult> GetSuggestionsAsync(
PlaybookSuggestionRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Generating playbook suggestions for CVE {CveId}, component {Component}",
request.Situation.CveId,
request.Situation.Component);
// Generate similarity vector for current situation
var situationVector = _vectorGenerator.Generate(request.Situation);
// Query similar situations with successful outcomes
var query = new SimilarityQuery
{
TenantId = request.TenantId,
SimilarityVector = situationVector,
Limit = request.TopK ?? DefaultTopK,
MinSimilarity = request.MinSimilarity ?? 0.6,
OnlyWithOutcome = true,
OnlySuccessful = true,
Since = request.Since
};
var similarRecords = await _store.FindSimilarAsync(query, cancellationToken)
.ConfigureAwait(false);
if (similarRecords.Count == 0)
{
_logger.LogDebug("No similar successful decisions found");
return new PlaybookSuggestionResult
{
Suggestions = ImmutableArray<PlaybookSuggestion>.Empty,
AnalyzedRecords = 0
};
}
// Group by action and rank by success rate and similarity
var suggestions = GroupAndRankSuggestions(
request.Situation,
similarRecords,
request.MaxSuggestions ?? MaxSuggestions);
return new PlaybookSuggestionResult
{
Suggestions = suggestions,
AnalyzedRecords = similarRecords.Count,
TopSimilarity = similarRecords.Max(r => r.SimilarityScore)
};
}
private ImmutableArray<PlaybookSuggestion> GroupAndRankSuggestions(
SituationContext currentSituation,
IReadOnlyList<SimilarityMatch> similarRecords,
int maxSuggestions)
{
// Group by action
var byAction = similarRecords
.GroupBy(r => r.Record.Decision.Action)
.Select(g => new
{
Action = g.Key,
Records = g.ToList(),
AvgSimilarity = g.Average(r => r.SimilarityScore),
SuccessCount = g.Count(r => r.Record.Outcome?.Status == OutcomeStatus.Success),
TotalCount = g.Count()
})
.OrderByDescending(g => g.SuccessCount)
.ThenByDescending(g => g.AvgSimilarity)
.Take(maxSuggestions)
.ToList();
var suggestions = new List<PlaybookSuggestion>();
foreach (var group in byAction)
{
var confidence = CalculateConfidence(
group.SuccessCount,
group.TotalCount,
group.AvgSimilarity);
if (confidence < MinConfidence)
{
continue;
}
// Get the best example for this action
var bestExample = group.Records
.OrderByDescending(r => r.SimilarityScore)
.First();
// Get matching factors
var factors = _vectorGenerator.GetMatchingFactors(
currentSituation,
bestExample.Record.Situation);
// Build rationale from past decisions
var rationales = group.Records
.Select(r => r.Record.Decision.Rationale)
.Distinct()
.Take(3)
.ToList();
suggestions.Add(new PlaybookSuggestion
{
Action = group.Action,
Confidence = confidence,
Rationale = BuildRationale(group.Action, rationales, factors),
Evidence = BuildEvidence(group.Records),
MatchingFactors = factors,
SimilarDecisionCount = group.TotalCount,
SuccessRate = (double)group.SuccessCount / group.TotalCount,
BestMatchMemoryId = bestExample.Record.MemoryId,
AverageResolutionTime = CalculateAverageResolutionTime(group.Records)
});
}
return suggestions
.OrderByDescending(s => s.Confidence)
.ToImmutableArray();
}
private static double CalculateConfidence(int successCount, int totalCount, double avgSimilarity)
{
// Confidence formula:
// - Base: success rate * similarity
// - Boost for more data points (log scale)
var successRate = (double)successCount / totalCount;
var dataBoost = Math.Min(1.0, Math.Log10(totalCount + 1) / 2);
return (successRate * 0.5 + avgSimilarity * 0.3 + dataBoost * 0.2);
}
private static string BuildRationale(
DecisionAction action,
List<string> pastRationales,
ImmutableArray<string> matchingFactors)
{
var parts = new List<string>();
// Start with the recommendation
parts.Add($"Recommended action: **{action}**");
// Add matching factors
if (matchingFactors.Length > 0)
{
parts.Add(string.Format(CultureInfo.InvariantCulture,
"This situation matches previous cases with: {0}.",
string.Join(", ", matchingFactors)));
}
// Add sample past rationales
if (pastRationales.Count > 0)
{
parts.Add(string.Format(CultureInfo.InvariantCulture,
"Past decisions used similar reasoning: \"{0}\"",
pastRationales[0]));
}
return string.Join(" ", parts);
}
private static ImmutableArray<PlaybookEvidence> BuildEvidence(List<SimilarityMatch> records)
{
return records
.OrderByDescending(r => r.SimilarityScore)
.Take(5)
.Select(r => new PlaybookEvidence
{
MemoryId = r.Record.MemoryId,
Similarity = r.SimilarityScore,
Action = r.Record.Decision.Action,
Outcome = r.Record.Outcome?.Status ?? OutcomeStatus.Pending,
DecidedAt = r.Record.Decision.DecidedAt,
Cve = r.Record.Situation.CveId,
Component = r.Record.Situation.ComponentName
})
.ToImmutableArray();
}
private static TimeSpan? CalculateAverageResolutionTime(List<SimilarityMatch> records)
{
var resolutionTimes = records
.Where(r => r.Record.Outcome?.ResolutionTime.HasValue == true)
.Select(r => r.Record.Outcome!.ResolutionTime!.Value)
.ToList();
if (resolutionTimes.Count == 0)
{
return null;
}
var avgTicks = resolutionTimes.Average(t => t.Ticks);
return TimeSpan.FromTicks((long)avgTicks);
}
}
/// <summary>
/// Request for playbook suggestions.
/// </summary>
public sealed record PlaybookSuggestionRequest
{
/// <summary>
/// Gets the tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the current situation to get suggestions for.
/// </summary>
public required SituationContext Situation { get; init; }
/// <summary>
/// Gets the maximum number of similar records to analyze.
/// </summary>
public int? TopK { get; init; }
/// <summary>
/// Gets the minimum similarity threshold.
/// </summary>
public double? MinSimilarity { get; init; }
/// <summary>
/// Gets the maximum number of suggestions to return.
/// </summary>
public int? MaxSuggestions { get; init; }
/// <summary>
/// Gets the earliest date for historical records.
/// </summary>
public DateTimeOffset? Since { get; init; }
}
/// <summary>
/// Result of playbook suggestion generation.
/// </summary>
public sealed record PlaybookSuggestionResult
{
/// <summary>
/// Gets the suggestions ordered by confidence.
/// </summary>
public ImmutableArray<PlaybookSuggestion> Suggestions { get; init; } =
ImmutableArray<PlaybookSuggestion>.Empty;
/// <summary>
/// Gets the number of historical records analyzed.
/// </summary>
public int AnalyzedRecords { get; init; }
/// <summary>
/// Gets the highest similarity score found.
/// </summary>
public double? TopSimilarity { get; init; }
/// <summary>
/// Gets whether any suggestions were found.
/// </summary>
public bool HasSuggestions => Suggestions.Length > 0;
}
/// <summary>
/// A playbook suggestion based on historical data.
/// </summary>
public sealed record PlaybookSuggestion
{
/// <summary>
/// Gets the recommended action.
/// </summary>
public required DecisionAction Action { get; init; }
/// <summary>
/// Gets the confidence level (0-1).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Gets the human-readable rationale.
/// </summary>
public required string Rationale { get; init; }
/// <summary>
/// Gets the evidence records supporting this suggestion.
/// </summary>
public ImmutableArray<PlaybookEvidence> Evidence { get; init; } =
ImmutableArray<PlaybookEvidence>.Empty;
/// <summary>
/// Gets the factors that matched the current situation.
/// </summary>
public ImmutableArray<string> MatchingFactors { get; init; } =
ImmutableArray<string>.Empty;
/// <summary>
/// Gets the number of similar past decisions.
/// </summary>
public int SimilarDecisionCount { get; init; }
/// <summary>
/// Gets the success rate for this action in similar situations.
/// </summary>
public double SuccessRate { get; init; }
/// <summary>
/// Gets the memory ID of the best matching past decision.
/// </summary>
public string? BestMatchMemoryId { get; init; }
/// <summary>
/// Gets the average resolution time for this action.
/// </summary>
public TimeSpan? AverageResolutionTime { get; init; }
}
/// <summary>
/// Evidence record for a playbook suggestion.
/// </summary>
public sealed record PlaybookEvidence
{
/// <summary>
/// Gets the memory record ID.
/// </summary>
public required string MemoryId { get; init; }
/// <summary>
/// Gets the similarity score to current situation.
/// </summary>
public required double Similarity { get; init; }
/// <summary>
/// Gets the action that was taken.
/// </summary>
public required DecisionAction Action { get; init; }
/// <summary>
/// Gets the outcome of the decision.
/// </summary>
public required OutcomeStatus Outcome { get; init; }
/// <summary>
/// Gets when the decision was made.
/// </summary>
public required DateTimeOffset DecidedAt { get; init; }
/// <summary>
/// Gets the CVE (if any).
/// </summary>
public string? Cve { get; init; }
/// <summary>
/// Gets the component name (if any).
/// </summary>
public string? Component { get; init; }
}

View File

@@ -0,0 +1,291 @@
// <copyright file="SimilarityVectorGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.OpsMemory.Models;
namespace StellaOps.OpsMemory.Similarity;
/// <summary>
/// Generates similarity vectors for finding related security decisions.
/// Sprint: SPRINT_20260107_006_004 Task OM-004
/// </summary>
public sealed class SimilarityVectorGenerator
{
// Vector dimensions:
// [0-9] : CVE category one-hot (10 categories)
// [10-14] : Severity one-hot (none, low, medium, high, critical)
// [15-18] : Reachability one-hot (unknown, reachable, not-reachable, potential)
// [19-23] : EPSS band one-hot (0-0.2, 0.2-0.4, 0.4-0.6, 0.6-0.8, 0.8-1.0)
// [24-28] : CVSS band one-hot (0-2, 2-4, 4-6, 6-8, 8-10)
// [29] : KEV flag
// [30-39] : Component type one-hot (10 types)
// [40-49] : Context tag presence (10 common tags)
private const int VectorDimension = 50;
private static readonly ImmutableArray<string> CveCategories = ImmutableArray.Create(
"memory", "injection", "auth", "crypto", "dos",
"info-disclosure", "privilege-escalation", "xss", "path-traversal", "other");
private static readonly ImmutableArray<string> ComponentTypes = ImmutableArray.Create(
"npm", "maven", "pypi", "nuget", "go", "cargo", "deb", "rpm", "apk", "other");
private static readonly ImmutableArray<string> CommonContextTags = ImmutableArray.Create(
"production", "development", "staging", "external-facing", "internal",
"payment", "auth", "data", "api", "frontend");
/// <summary>
/// Generates a similarity vector from a situation context.
/// </summary>
/// <param name="situation">The situation to vectorize.</param>
/// <returns>A normalized similarity vector.</returns>
public ImmutableArray<float> Generate(SituationContext situation)
{
var vector = new float[VectorDimension];
// CVE category (dimensions 0-9)
var category = ClassifyCve(situation.CveId);
var categoryIndex = CveCategories.IndexOf(category);
if (categoryIndex >= 0 && categoryIndex < 10)
{
vector[categoryIndex] = 1.0f;
}
// Severity (dimensions 10-14)
var severityIndex = GetSeverityIndex(situation.Severity);
if (severityIndex >= 0)
{
vector[10 + severityIndex] = 1.0f;
}
// Reachability (dimensions 15-18)
vector[15 + (int)situation.Reachability] = 1.0f;
// EPSS band (dimensions 19-23)
if (situation.EpssScore.HasValue)
{
var epssBand = GetBandIndex(situation.EpssScore.Value, 0, 1, 5);
vector[19 + epssBand] = 1.0f;
}
// CVSS band (dimensions 24-28)
if (situation.CvssScore.HasValue)
{
var cvssBand = GetBandIndex(situation.CvssScore.Value, 0, 10, 5);
vector[24 + cvssBand] = 1.0f;
}
// KEV flag (dimension 29)
vector[29] = situation.IsKev ? 1.0f : 0.0f;
// Component type (dimensions 30-39)
var componentType = ClassifyComponent(situation.Component);
var typeIndex = ComponentTypes.IndexOf(componentType);
if (typeIndex >= 0 && typeIndex < 10)
{
vector[30 + typeIndex] = 1.0f;
}
// Context tags (dimensions 40-49)
foreach (var tag in situation.ContextTags)
{
var tagLower = tag.ToLowerInvariant();
var tagIndex = CommonContextTags.IndexOf(tagLower);
if (tagIndex >= 0 && tagIndex < 10)
{
vector[40 + tagIndex] = 1.0f;
}
}
// Normalize to unit vector
return Normalize(vector);
}
/// <summary>
/// Computes cosine similarity between two vectors.
/// </summary>
public static double CosineSimilarity(ImmutableArray<float> a, ImmutableArray<float> b)
{
if (a.Length != b.Length)
{
throw new ArgumentException("Vectors must have the same dimension");
}
double dotProduct = 0;
double normA = 0;
double normB = 0;
for (int i = 0; i < a.Length; i++)
{
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA == 0 || normB == 0)
{
return 0;
}
return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
}
/// <summary>
/// Gets the factors that contributed to similarity.
/// </summary>
public ImmutableArray<string> GetMatchingFactors(
SituationContext a,
SituationContext b)
{
var factors = new List<string>();
// Check CVE category match
var catA = ClassifyCve(a.CveId);
var catB = ClassifyCve(b.CveId);
if (catA == catB && catA != "other")
{
factors.Add($"Same vulnerability category: {catA}");
}
// Check severity match
if (a.Severity?.ToLowerInvariant() == b.Severity?.ToLowerInvariant() &&
!string.IsNullOrEmpty(a.Severity))
{
factors.Add($"Same severity: {a.Severity}");
}
// Check reachability match
if (a.Reachability == b.Reachability && a.Reachability != ReachabilityStatus.Unknown)
{
factors.Add($"Same reachability: {a.Reachability}");
}
// Check similar EPSS
if (a.EpssScore.HasValue && b.EpssScore.HasValue)
{
if (Math.Abs(a.EpssScore.Value - b.EpssScore.Value) < 0.2)
{
factors.Add(string.Format(CultureInfo.InvariantCulture,
"Similar EPSS: {0:P0} vs {1:P0}",
a.EpssScore.Value, b.EpssScore.Value));
}
}
// Check KEV match
if (a.IsKev && b.IsKev)
{
factors.Add("Both are KEV");
}
// Check component type match
var typeA = ClassifyComponent(a.Component);
var typeB = ClassifyComponent(b.Component);
if (typeA == typeB && typeA != "other")
{
factors.Add($"Same component type: {typeA}");
}
// Check overlapping context tags
var commonTags = a.ContextTags.Intersect(b.ContextTags, StringComparer.OrdinalIgnoreCase).ToList();
if (commonTags.Count > 0)
{
factors.Add($"Shared context: {string.Join(", ", commonTags)}");
}
return factors.ToImmutableArray();
}
private static string ClassifyCve(string? cveId)
{
if (string.IsNullOrEmpty(cveId))
{
return "other";
}
// Simple heuristic classification based on CVE patterns
// In production, this would query a CVE database or use CWE mapping
var cveLower = cveId.ToLowerInvariant();
// These are placeholder classifications - real implementation would use CWE data
return "other";
}
private static string ClassifyComponent(string? purl)
{
if (string.IsNullOrEmpty(purl))
{
return "other";
}
// Parse PURL type
if (purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase))
return "npm";
if (purl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase))
return "maven";
if (purl.StartsWith("pkg:pypi/", StringComparison.OrdinalIgnoreCase))
return "pypi";
if (purl.StartsWith("pkg:nuget/", StringComparison.OrdinalIgnoreCase))
return "nuget";
if (purl.StartsWith("pkg:golang/", StringComparison.OrdinalIgnoreCase))
return "go";
if (purl.StartsWith("pkg:cargo/", StringComparison.OrdinalIgnoreCase))
return "cargo";
if (purl.StartsWith("pkg:deb/", StringComparison.OrdinalIgnoreCase))
return "deb";
if (purl.StartsWith("pkg:rpm/", StringComparison.OrdinalIgnoreCase))
return "rpm";
if (purl.StartsWith("pkg:apk/", StringComparison.OrdinalIgnoreCase))
return "apk";
return "other";
}
private static int GetSeverityIndex(string? severity)
{
if (string.IsNullOrEmpty(severity))
{
return 0;
}
return severity.ToLowerInvariant() switch
{
"none" => 0,
"low" => 1,
"medium" => 2,
"high" => 3,
"critical" => 4,
_ => 0
};
}
private static int GetBandIndex(double value, double min, double max, int bands)
{
var normalized = (value - min) / (max - min);
var band = (int)(normalized * bands);
return Math.Clamp(band, 0, bands - 1);
}
private static ImmutableArray<float> Normalize(float[] vector)
{
double norm = 0;
foreach (var v in vector)
{
norm += v * v;
}
if (norm == 0)
{
return vector.ToImmutableArray();
}
var normSqrt = (float)Math.Sqrt(norm);
for (int i = 0; i < vector.Length; i++)
{
vector[i] /= normSqrt;
}
return vector.ToImmutableArray();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>OpsMemory - Decision ledger for security playbook learning</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,305 @@
// <copyright file="IOpsMemoryStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.OpsMemory.Models;
namespace StellaOps.OpsMemory.Storage;
/// <summary>
/// Interface for OpsMemory storage operations.
/// Sprint: SPRINT_20260107_006_004 Task OM-002
/// </summary>
public interface IOpsMemoryStore
{
/// <summary>
/// Records a new security decision.
/// </summary>
/// <param name="record">The decision record to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created record with assigned ID.</returns>
Task<OpsMemoryRecord> RecordDecisionAsync(
OpsMemoryRecord record,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the outcome of a previously made decision.
/// </summary>
/// <param name="memoryId">The memory record ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="outcome">The outcome details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated record.</returns>
Task<OpsMemoryRecord?> RecordOutcomeAsync(
string memoryId,
string tenantId,
OutcomeRecord outcome,
CancellationToken cancellationToken = default);
/// <summary>
/// Finds decisions similar to the given situation.
/// </summary>
/// <param name="query">The similarity query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Similar records ordered by similarity score.</returns>
Task<IReadOnlyList<SimilarityMatch>> FindSimilarAsync(
SimilarityQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific memory record by ID.
/// </summary>
/// <param name="memoryId">The memory record ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The record or null if not found.</returns>
Task<OpsMemoryRecord?> GetByIdAsync(
string memoryId,
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Queries memory records with filters.
/// </summary>
/// <param name="query">The query parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Matching records with pagination info.</returns>
Task<PagedResult<OpsMemoryRecord>> QueryAsync(
OpsMemoryQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statistics for playbook analysis.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="since">Optional start date for statistics.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Aggregated statistics.</returns>
Task<OpsMemoryStats> GetStatsAsync(
string tenantId,
DateTimeOffset? since = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Query for finding similar situations.
/// </summary>
public sealed record SimilarityQuery
{
/// <summary>
/// Gets the tenant ID for isolation.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the similarity vector to match against.
/// </summary>
public ImmutableArray<float> SimilarityVector { get; init; } = ImmutableArray<float>.Empty;
/// <summary>
/// Gets the situation to match (used if vector not provided).
/// </summary>
public SituationContext? Situation { get; init; }
/// <summary>
/// Gets the maximum number of results (default: 10).
/// </summary>
public int Limit { get; init; } = 10;
/// <summary>
/// Gets the minimum similarity threshold (0-1, default: 0.7).
/// </summary>
public double MinSimilarity { get; init; } = 0.7;
/// <summary>
/// Gets whether to include only records with outcomes.
/// </summary>
public bool OnlyWithOutcome { get; init; } = false;
/// <summary>
/// Gets whether to include only successful outcomes.
/// </summary>
public bool OnlySuccessful { get; init; } = false;
/// <summary>
/// Gets the earliest record date to consider.
/// </summary>
public DateTimeOffset? Since { get; init; }
}
/// <summary>
/// A similarity match result.
/// </summary>
public sealed record SimilarityMatch
{
/// <summary>
/// Gets the matching memory record.
/// </summary>
public required OpsMemoryRecord Record { get; init; }
/// <summary>
/// Gets the similarity score (0-1).
/// </summary>
public required double SimilarityScore { get; init; }
/// <summary>
/// Gets the matching factors that contributed to similarity.
/// </summary>
public ImmutableArray<string> MatchingFactors { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Query for listing memory records.
/// </summary>
public sealed record OpsMemoryQuery
{
/// <summary>
/// Gets the tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the CVE filter (exact match).
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Gets the component PURL filter (prefix match).
/// </summary>
public string? ComponentPrefix { get; init; }
/// <summary>
/// Gets the action filter.
/// </summary>
public DecisionAction? Action { get; init; }
/// <summary>
/// Gets the outcome status filter.
/// </summary>
public OutcomeStatus? OutcomeStatus { get; init; }
/// <summary>
/// Gets the start date filter.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Gets the end date filter.
/// </summary>
public DateTimeOffset? Until { get; init; }
/// <summary>
/// Gets the context tag filter (any match).
/// </summary>
public ImmutableArray<string> ContextTags { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the page size (default: 20).
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// Gets the cursor for pagination.
/// </summary>
public string? Cursor { get; init; }
/// <summary>
/// Gets the sort field.
/// </summary>
public OpsMemorySortField SortBy { get; init; } = OpsMemorySortField.RecordedAt;
/// <summary>
/// Gets whether to sort descending.
/// </summary>
public bool Descending { get; init; } = true;
}
/// <summary>
/// Sort fields for memory queries.
/// </summary>
public enum OpsMemorySortField
{
/// <summary>Sort by record creation time.</summary>
RecordedAt,
/// <summary>Sort by decision time.</summary>
DecidedAt,
/// <summary>Sort by CVSS score.</summary>
CvssScore,
/// <summary>Sort by EPSS score.</summary>
EpssScore
}
/// <summary>
/// Paginated result.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
public sealed record PagedResult<T>
{
/// <summary>
/// Gets the items in this page.
/// </summary>
public required IReadOnlyList<T> Items { get; init; }
/// <summary>
/// Gets the total count (if available).
/// </summary>
public int? TotalCount { get; init; }
/// <summary>
/// Gets the cursor for the next page.
/// </summary>
public string? NextCursor { get; init; }
/// <summary>
/// Gets whether there are more results.
/// </summary>
public bool HasMore => NextCursor is not null;
}
/// <summary>
/// Aggregated statistics for OpsMemory.
/// </summary>
public sealed record OpsMemoryStats
{
/// <summary>
/// Gets the total number of decisions.
/// </summary>
public int TotalDecisions { get; init; }
/// <summary>
/// Gets the number of decisions with outcomes recorded.
/// </summary>
public int DecisionsWithOutcomes { get; init; }
/// <summary>
/// Gets the success rate (0-1).
/// </summary>
public double SuccessRate { get; init; }
/// <summary>
/// Gets the breakdown by action.
/// </summary>
public ImmutableDictionary<DecisionAction, int> ByAction { get; init; } =
ImmutableDictionary<DecisionAction, int>.Empty;
/// <summary>
/// Gets the breakdown by outcome status.
/// </summary>
public ImmutableDictionary<OutcomeStatus, int> ByOutcome { get; init; } =
ImmutableDictionary<OutcomeStatus, int>.Empty;
/// <summary>
/// Gets the average resolution time.
/// </summary>
public TimeSpan? AverageResolutionTime { get; init; }
/// <summary>
/// Gets the most common context tags.
/// </summary>
public ImmutableArray<(string Tag, int Count)> TopContextTags { get; init; } =
ImmutableArray<(string, int)>.Empty;
}

View File

@@ -0,0 +1,696 @@
// <copyright file="PostgresOpsMemoryStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.OpsMemory.Models;
namespace StellaOps.OpsMemory.Storage;
/// <summary>
/// PostgreSQL implementation of IOpsMemoryStore.
/// Sprint: SPRINT_20260107_006_004 Task OM-003
/// </summary>
public sealed class PostgresOpsMemoryStore : IOpsMemoryStore, IAsyncDisposable
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresOpsMemoryStore> _logger;
private readonly OpsMemoryStoreOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Initializes a new instance of the <see cref="PostgresOpsMemoryStore"/> class.
/// </summary>
public PostgresOpsMemoryStore(
NpgsqlDataSource dataSource,
ILogger<PostgresOpsMemoryStore> logger,
OpsMemoryStoreOptions? options = null)
{
_dataSource = dataSource;
_logger = logger;
_options = options ?? new OpsMemoryStoreOptions();
}
/// <inheritdoc />
public async Task<OpsMemoryRecord> RecordDecisionAsync(
OpsMemoryRecord record,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO opsmemory.decisions (
memory_id, tenant_id, recorded_at,
cve_id, component, component_name, component_version,
severity, cvss_score, reachability, epss_score, is_kev,
context_tags, additional_context,
action, rationale, decided_by, decided_at,
policy_reference, vex_statement_id, mitigation,
similarity_vector
) VALUES (
@memoryId, @tenantId, @recordedAt,
@cveId, @component, @componentName, @componentVersion,
@severity, @cvssScore, @reachability, @epssScore, @isKev,
@contextTags, @additionalContext,
@action, @rationale, @decidedBy, @decidedAt,
@policyReference, @vexStatementId, @mitigation,
@similarityVector
)
""";
await using var cmd = _dataSource.CreateCommand(sql);
AddRecordParameters(cmd, record);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Recorded decision {MemoryId} for tenant {TenantId}: {Action}",
record.MemoryId, record.TenantId, record.Decision.Action);
return record;
}
/// <inheritdoc />
public async Task<OpsMemoryRecord?> RecordOutcomeAsync(
string memoryId,
string tenantId,
OutcomeRecord outcome,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE opsmemory.decisions
SET outcome_status = @status,
outcome_resolution_time = @resolutionTime,
outcome_actual_impact = @actualImpact,
outcome_lessons_learned = @lessonsLearned,
outcome_recorded_by = @recordedBy,
outcome_recorded_at = @recordedAt,
outcome_would_repeat = @wouldRepeat,
outcome_alternative_actions = @alternativeActions
WHERE memory_id = @memoryId AND tenant_id = @tenantId
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("memoryId", memoryId);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("status", outcome.Status.ToString());
cmd.Parameters.AddWithValue("resolutionTime",
(object?)outcome.ResolutionTime?.TotalSeconds ?? DBNull.Value);
cmd.Parameters.AddWithValue("actualImpact", (object?)outcome.ActualImpact ?? DBNull.Value);
cmd.Parameters.AddWithValue("lessonsLearned", (object?)outcome.LessonsLearned ?? DBNull.Value);
cmd.Parameters.AddWithValue("recordedBy", outcome.RecordedBy);
cmd.Parameters.AddWithValue("recordedAt", outcome.RecordedAt);
cmd.Parameters.AddWithValue("wouldRepeat", (object?)outcome.WouldRepeat ?? DBNull.Value);
cmd.Parameters.AddWithValue("alternativeActions", (object?)outcome.AlternativeActions ?? DBNull.Value);
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (affected == 0)
{
_logger.LogWarning("Decision {MemoryId} not found for tenant {TenantId}", memoryId, tenantId);
return null;
}
_logger.LogInformation(
"Recorded outcome for decision {MemoryId}: {Status}",
memoryId, outcome.Status);
return await GetByIdAsync(memoryId, tenantId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<OpsMemoryRecord?> GetByIdAsync(
string memoryId,
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM opsmemory.decisions
WHERE memory_id = @memoryId AND tenant_id = @tenantId
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("memoryId", memoryId);
cmd.Parameters.AddWithValue("tenantId", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapRecord(reader);
}
/// <inheritdoc />
public async Task<PagedResult<OpsMemoryRecord>> QueryAsync(
OpsMemoryQuery query,
CancellationToken cancellationToken = default)
{
var whereClauses = new List<string> { "tenant_id = @tenantId" };
var parameters = new List<NpgsqlParameter> { new("tenantId", query.TenantId) };
if (!string.IsNullOrEmpty(query.CveId))
{
whereClauses.Add("cve_id = @cveId");
parameters.Add(new NpgsqlParameter("cveId", query.CveId));
}
if (!string.IsNullOrEmpty(query.ComponentPrefix))
{
whereClauses.Add("component LIKE @componentPrefix");
parameters.Add(new NpgsqlParameter("componentPrefix", query.ComponentPrefix + "%"));
}
if (query.Action.HasValue)
{
whereClauses.Add("action = @action");
parameters.Add(new NpgsqlParameter("action", query.Action.Value.ToString()));
}
if (query.OutcomeStatus.HasValue)
{
whereClauses.Add("outcome_status = @outcomeStatus");
parameters.Add(new NpgsqlParameter("outcomeStatus", query.OutcomeStatus.Value.ToString()));
}
if (query.Since.HasValue)
{
whereClauses.Add("recorded_at >= @since");
parameters.Add(new NpgsqlParameter("since", query.Since.Value));
}
if (query.Until.HasValue)
{
whereClauses.Add("recorded_at <= @until");
parameters.Add(new NpgsqlParameter("until", query.Until.Value));
}
if (!query.ContextTags.IsDefaultOrEmpty)
{
whereClauses.Add("context_tags && @contextTags");
parameters.Add(new NpgsqlParameter("contextTags", query.ContextTags.ToArray()));
}
var whereClause = string.Join(" AND ", whereClauses);
var sortColumn = query.SortBy switch
{
OpsMemorySortField.DecidedAt => "decided_at",
OpsMemorySortField.CvssScore => "cvss_score",
OpsMemorySortField.EpssScore => "epss_score",
_ => "recorded_at"
};
var sortDir = query.Descending ? "DESC" : "ASC";
// Count query
var countSql = $"SELECT COUNT(*) FROM opsmemory.decisions WHERE {whereClause}";
await using var countCmd = _dataSource.CreateCommand(countSql);
foreach (var p in parameters)
{
countCmd.Parameters.Add(CloneParameter(p));
}
var totalCount = Convert.ToInt32(
await countCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false),
CultureInfo.InvariantCulture);
// Select query with pagination
var selectSql = $"""
SELECT * FROM opsmemory.decisions
WHERE {whereClause}
ORDER BY {sortColumn} {sortDir}
LIMIT {query.PageSize + 1}
""";
if (!string.IsNullOrEmpty(query.Cursor))
{
selectSql = $"""
SELECT * FROM opsmemory.decisions
WHERE {whereClause} AND recorded_at < @cursor
ORDER BY {sortColumn} {sortDir}
LIMIT {query.PageSize + 1}
""";
parameters.Add(new NpgsqlParameter("cursor", DateTimeOffset.Parse(query.Cursor, CultureInfo.InvariantCulture)));
}
await using var selectCmd = _dataSource.CreateCommand(selectSql);
foreach (var p in parameters)
{
selectCmd.Parameters.Add(CloneParameter(p));
}
var records = new List<OpsMemoryRecord>();
await using var reader = await selectCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
records.Add(MapRecord(reader));
}
string? nextCursor = null;
if (records.Count > query.PageSize)
{
records.RemoveAt(records.Count - 1);
nextCursor = records[^1].RecordedAt.ToString("O", CultureInfo.InvariantCulture);
}
return new PagedResult<OpsMemoryRecord>
{
Items = records,
TotalCount = totalCount,
NextCursor = nextCursor
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<SimilarityMatch>> FindSimilarAsync(
SimilarityQuery query,
CancellationToken cancellationToken = default)
{
// Without pgvector, we fetch candidates and compute similarity in-memory
var whereClauses = new List<string> { "tenant_id = @tenantId" };
var parameters = new List<NpgsqlParameter> { new("tenantId", query.TenantId) };
if (query.OnlyWithOutcome)
{
whereClauses.Add("outcome_status IS NOT NULL");
}
if (query.OnlySuccessful)
{
whereClauses.Add("outcome_status = 'Success'");
}
if (query.Since.HasValue)
{
whereClauses.Add("recorded_at >= @since");
parameters.Add(new NpgsqlParameter("since", query.Since.Value));
}
var whereClause = string.Join(" AND ", whereClauses);
var sql = $"""
SELECT * FROM opsmemory.decisions
WHERE {whereClause}
AND similarity_vector IS NOT NULL
AND array_length(similarity_vector, 1) > 0
ORDER BY recorded_at DESC
LIMIT 100
""";
await using var cmd = _dataSource.CreateCommand(sql);
foreach (var p in parameters)
{
cmd.Parameters.Add(p);
}
var candidates = new List<(OpsMemoryRecord Record, float[] Vector)>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var record = MapRecord(reader);
var vectorOrdinal = reader.GetOrdinal("similarity_vector");
if (!reader.IsDBNull(vectorOrdinal))
{
var vector = (float[])reader.GetValue(vectorOrdinal);
if (vector.Length > 0)
{
candidates.Add((record, vector));
}
}
}
// Compute cosine similarity
var queryVector = query.SimilarityVector.ToArray();
var matches = candidates
.Select(c => new
{
c.Record,
Similarity = CosineSimilarity(queryVector, c.Vector)
})
.Where(m => m.Similarity >= query.MinSimilarity)
.OrderByDescending(m => m.Similarity)
.Take(query.Limit)
.Select(m => new SimilarityMatch
{
Record = m.Record,
SimilarityScore = m.Similarity,
MatchingFactors = DetermineMatchingFactors(m.Record, query.Situation)
})
.ToList();
_logger.LogDebug(
"Found {Count} similar records for tenant {TenantId} (checked {Candidates} candidates)",
matches.Count, query.TenantId, candidates.Count);
return matches;
}
/// <inheritdoc />
public async Task<OpsMemoryStats> GetStatsAsync(
string tenantId,
DateTimeOffset? since = null,
CancellationToken cancellationToken = default)
{
var whereClause = "tenant_id = @tenantId";
var parameters = new List<NpgsqlParameter> { new("tenantId", tenantId) };
if (since.HasValue)
{
whereClause += " AND recorded_at >= @since";
parameters.Add(new NpgsqlParameter("since", since.Value));
}
var sql = $"""
SELECT
COUNT(*) as total_decisions,
COUNT(outcome_status) as decisions_with_outcomes,
COUNT(*) FILTER (WHERE outcome_status = 'Success') as successful_outcomes,
AVG(outcome_resolution_time) FILTER (WHERE outcome_resolution_time IS NOT NULL) as avg_resolution_time
FROM opsmemory.decisions
WHERE {whereClause}
""";
await using var cmd = _dataSource.CreateCommand(sql);
foreach (var p in parameters)
{
cmd.Parameters.Add(p);
}
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var totalDecisions = reader.GetInt32(0);
var decisionsWithOutcomes = reader.GetInt32(1);
var successfulOutcomes = reader.GetInt32(2);
var avgResolutionSeconds = reader.IsDBNull(3) ? (double?)null : reader.GetDouble(3);
// Get breakdown by action
var actionSql = $"""
SELECT action, COUNT(*) as count
FROM opsmemory.decisions
WHERE {whereClause}
GROUP BY action
""";
await using var actionCmd = _dataSource.CreateCommand(actionSql);
foreach (var p in parameters)
{
actionCmd.Parameters.Add(CloneParameter(p));
}
var byAction = ImmutableDictionary.CreateBuilder<DecisionAction, int>();
await using var actionReader = await actionCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await actionReader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (Enum.TryParse<DecisionAction>(actionReader.GetString(0), out var action))
{
byAction[action] = actionReader.GetInt32(1);
}
}
// Get breakdown by outcome
var outcomeSql = $"""
SELECT outcome_status, COUNT(*) as count
FROM opsmemory.decisions
WHERE {whereClause} AND outcome_status IS NOT NULL
GROUP BY outcome_status
""";
await using var outcomeCmd = _dataSource.CreateCommand(outcomeSql);
foreach (var p in parameters)
{
outcomeCmd.Parameters.Add(CloneParameter(p));
}
var byOutcome = ImmutableDictionary.CreateBuilder<OutcomeStatus, int>();
await using var outcomeReader = await outcomeCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await outcomeReader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (Enum.TryParse<OutcomeStatus>(outcomeReader.GetString(0), out var status))
{
byOutcome[status] = outcomeReader.GetInt32(1);
}
}
return new OpsMemoryStats
{
TotalDecisions = totalDecisions,
DecisionsWithOutcomes = decisionsWithOutcomes,
SuccessRate = decisionsWithOutcomes > 0
? (double)successfulOutcomes / decisionsWithOutcomes
: 0,
ByAction = byAction.ToImmutable(),
ByOutcome = byOutcome.ToImmutable(),
AverageResolutionTime = avgResolutionSeconds.HasValue
? TimeSpan.FromSeconds(avgResolutionSeconds.Value)
: null
};
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// DataSource is managed externally
await Task.CompletedTask;
}
private static void AddRecordParameters(NpgsqlCommand cmd, OpsMemoryRecord record)
{
cmd.Parameters.AddWithValue("memoryId", record.MemoryId);
cmd.Parameters.AddWithValue("tenantId", record.TenantId);
cmd.Parameters.AddWithValue("recordedAt", record.RecordedAt);
// Situation
cmd.Parameters.AddWithValue("cveId", (object?)record.Situation.CveId ?? DBNull.Value);
cmd.Parameters.AddWithValue("component", (object?)record.Situation.Component ?? DBNull.Value);
cmd.Parameters.AddWithValue("componentName", (object?)record.Situation.ComponentName ?? DBNull.Value);
cmd.Parameters.AddWithValue("componentVersion", (object?)record.Situation.ComponentVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("severity", (object?)record.Situation.Severity ?? DBNull.Value);
cmd.Parameters.AddWithValue("cvssScore", (object?)record.Situation.CvssScore ?? DBNull.Value);
cmd.Parameters.AddWithValue("reachability", record.Situation.Reachability.ToString());
cmd.Parameters.AddWithValue("epssScore", (object?)record.Situation.EpssScore ?? DBNull.Value);
cmd.Parameters.AddWithValue("isKev", record.Situation.IsKev);
cmd.Parameters.AddWithValue("contextTags", record.Situation.ContextTags.IsDefaultOrEmpty
? Array.Empty<string>()
: record.Situation.ContextTags.ToArray());
var additionalContext = record.Situation.AdditionalContext.IsEmpty
? null
: JsonSerializer.Serialize(record.Situation.AdditionalContext, JsonOptions);
cmd.Parameters.Add(new NpgsqlParameter("additionalContext", NpgsqlDbType.Jsonb)
{
Value = (object?)additionalContext ?? DBNull.Value
});
// Decision
cmd.Parameters.AddWithValue("action", record.Decision.Action.ToString());
cmd.Parameters.AddWithValue("rationale", record.Decision.Rationale);
cmd.Parameters.AddWithValue("decidedBy", record.Decision.DecidedBy);
cmd.Parameters.AddWithValue("decidedAt", record.Decision.DecidedAt);
cmd.Parameters.AddWithValue("policyReference", (object?)record.Decision.PolicyReference ?? DBNull.Value);
cmd.Parameters.AddWithValue("vexStatementId", (object?)record.Decision.VexStatementId ?? DBNull.Value);
var mitigation = record.Decision.Mitigation is null
? null
: JsonSerializer.Serialize(record.Decision.Mitigation, JsonOptions);
cmd.Parameters.Add(new NpgsqlParameter("mitigation", NpgsqlDbType.Jsonb)
{
Value = (object?)mitigation ?? DBNull.Value
});
// Similarity vector
cmd.Parameters.AddWithValue("similarityVector", record.SimilarityVector.IsDefaultOrEmpty
? Array.Empty<float>()
: record.SimilarityVector.ToArray());
}
private static OpsMemoryRecord MapRecord(NpgsqlDataReader reader)
{
OutcomeRecord? outcome = null;
var outcomeStatusOrdinal = reader.GetOrdinal("outcome_status");
if (!reader.IsDBNull(outcomeStatusOrdinal))
{
var resolutionTimeOrdinal = reader.GetOrdinal("outcome_resolution_time");
TimeSpan? resolutionTime = reader.IsDBNull(resolutionTimeOrdinal)
? null
: TimeSpan.FromSeconds(reader.GetDouble(resolutionTimeOrdinal));
outcome = new OutcomeRecord
{
Status = Enum.Parse<OutcomeStatus>(reader.GetString(outcomeStatusOrdinal)),
ResolutionTime = resolutionTime,
ActualImpact = GetNullableString(reader, "outcome_actual_impact"),
LessonsLearned = GetNullableString(reader, "outcome_lessons_learned"),
RecordedBy = reader.GetString(reader.GetOrdinal("outcome_recorded_by")),
RecordedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("outcome_recorded_at")),
WouldRepeat = GetNullableBool(reader, "outcome_would_repeat"),
AlternativeActions = GetNullableString(reader, "outcome_alternative_actions")
};
}
var contextTagsOrdinal = reader.GetOrdinal("context_tags");
var contextTags = reader.IsDBNull(contextTagsOrdinal)
? ImmutableArray<string>.Empty
: ((string[])reader.GetValue(contextTagsOrdinal)).ToImmutableArray();
var additionalContextOrdinal = reader.GetOrdinal("additional_context");
var additionalContext = reader.IsDBNull(additionalContextOrdinal)
? ImmutableDictionary<string, string>.Empty
: JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(
reader.GetString(additionalContextOrdinal), JsonOptions)
?? ImmutableDictionary<string, string>.Empty;
var mitigationOrdinal = reader.GetOrdinal("mitigation");
MitigationDetails? mitigation = reader.IsDBNull(mitigationOrdinal)
? null
: JsonSerializer.Deserialize<MitigationDetails>(reader.GetString(mitigationOrdinal), JsonOptions);
var vectorOrdinal = reader.GetOrdinal("similarity_vector");
var similarityVector = reader.IsDBNull(vectorOrdinal)
? ImmutableArray<float>.Empty
: ((float[])reader.GetValue(vectorOrdinal)).ToImmutableArray();
return new OpsMemoryRecord
{
MemoryId = reader.GetString(reader.GetOrdinal("memory_id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
RecordedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
Situation = new SituationContext
{
CveId = GetNullableString(reader, "cve_id"),
Component = GetNullableString(reader, "component"),
ComponentName = GetNullableString(reader, "component_name"),
ComponentVersion = GetNullableString(reader, "component_version"),
Severity = GetNullableString(reader, "severity"),
CvssScore = GetNullableDouble(reader, "cvss_score"),
Reachability = Enum.Parse<ReachabilityStatus>(
reader.GetString(reader.GetOrdinal("reachability"))),
EpssScore = GetNullableDouble(reader, "epss_score"),
IsKev = reader.GetBoolean(reader.GetOrdinal("is_kev")),
ContextTags = contextTags,
AdditionalContext = additionalContext
},
Decision = new DecisionRecord
{
Action = Enum.Parse<DecisionAction>(reader.GetString(reader.GetOrdinal("action"))),
Rationale = reader.GetString(reader.GetOrdinal("rationale")),
DecidedBy = reader.GetString(reader.GetOrdinal("decided_by")),
DecidedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("decided_at")),
PolicyReference = GetNullableString(reader, "policy_reference"),
VexStatementId = GetNullableString(reader, "vex_statement_id"),
Mitigation = mitigation
},
Outcome = outcome,
SimilarityVector = similarityVector
};
}
private static string? GetNullableString(NpgsqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
}
private static double? GetNullableDouble(NpgsqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetDouble(ordinal);
}
private static bool? GetNullableBool(NpgsqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetBoolean(ordinal);
}
private static NpgsqlParameter CloneParameter(NpgsqlParameter p) =>
new(p.ParameterName, p.Value);
private static double CosineSimilarity(float[] a, float[] b)
{
if (a.Length != b.Length || a.Length == 0)
{
return 0;
}
double dotProduct = 0;
double normA = 0;
double normB = 0;
for (int i = 0; i < a.Length; i++)
{
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA == 0 || normB == 0)
{
return 0;
}
return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
}
private static ImmutableArray<string> DetermineMatchingFactors(OpsMemoryRecord record, SituationContext? query)
{
if (query is null)
{
return ImmutableArray<string>.Empty;
}
var factors = new List<string>();
if (query.CveId == record.Situation.CveId)
{
factors.Add("same_cve");
}
if (query.Severity == record.Situation.Severity)
{
factors.Add("same_severity");
}
if (query.Reachability == record.Situation.Reachability)
{
factors.Add("same_reachability");
}
if (query.IsKev == record.Situation.IsKev)
{
factors.Add("same_kev_status");
}
if (!query.ContextTags.IsDefaultOrEmpty && !record.Situation.ContextTags.IsDefaultOrEmpty)
{
var overlap = query.ContextTags.Intersect(record.Situation.ContextTags).ToList();
if (overlap.Count > 0)
{
factors.Add($"shared_tags:{string.Join(",", overlap)}");
}
}
return factors.ToImmutableArray();
}
}
/// <summary>
/// Options for OpsMemory store.
/// </summary>
public sealed class OpsMemoryStoreOptions
{
/// <summary>
/// Gets or sets the default page size.
/// </summary>
public int DefaultPageSize { get; set; } = 20;
}

View File

@@ -0,0 +1,420 @@
// <copyright file="OutcomeTrackingService.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.Models;
using StellaOps.OpsMemory.Storage;
namespace StellaOps.OpsMemory.Tracking;
/// <summary>
/// Tracks outcomes of decisions and links them back to OpsMemory.
/// Sprint: SPRINT_20260107_006_004 Task OM-008
/// </summary>
public sealed class OutcomeTrackingService : IOutcomeTrackingService
{
private readonly IOpsMemoryStore _store;
private readonly TimeProvider _timeProvider;
private readonly ILogger<OutcomeTrackingService> _logger;
private readonly OutcomeTrackingOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="OutcomeTrackingService"/> class.
/// </summary>
public OutcomeTrackingService(
IOpsMemoryStore store,
TimeProvider timeProvider,
ILogger<OutcomeTrackingService> logger,
OutcomeTrackingOptions? options = null)
{
_store = store;
_timeProvider = timeProvider;
_logger = logger;
_options = options ?? new OutcomeTrackingOptions();
}
/// <summary>
/// Detects if a resolution event corresponds to a tracked decision.
/// </summary>
public async Task<OutcomePrompt?> DetectResolutionAsync(
ResolutionEvent resolutionEvent,
CancellationToken cancellationToken = default)
{
// Query for decisions matching the CVE and component
var query = new OpsMemoryQuery
{
TenantId = resolutionEvent.TenantId,
CveId = resolutionEvent.CveId,
ComponentPrefix = resolutionEvent.ComponentPurl,
PageSize = 10
};
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
// Find decisions without outcomes
var pendingDecisions = result.Items
.Where(r => !r.HasOutcome)
.OrderByDescending(r => r.RecordedAt)
.ToList();
if (pendingDecisions.Count == 0)
{
_logger.LogDebug(
"No pending decisions found for CVE {CveId} on {Component}",
resolutionEvent.CveId,
resolutionEvent.ComponentPurl);
return null;
}
var decision = pendingDecisions[0];
var elapsed = resolutionEvent.ResolvedAt - decision.Decision.DecidedAt;
_logger.LogInformation(
"Resolution detected for decision {MemoryId}: {ResolutionType} after {Elapsed}",
decision.MemoryId,
resolutionEvent.ResolutionType,
elapsed);
return new OutcomePrompt
{
MemoryId = decision.MemoryId,
TenantId = decision.TenantId,
CveId = resolutionEvent.CveId,
Component = resolutionEvent.ComponentPurl,
DecisionAction = decision.Decision.Action.ToString(),
DecidedAt = decision.Decision.DecidedAt,
SuggestedOutcome = MapResolutionToOutcome(resolutionEvent.ResolutionType, decision.Decision.Action),
ResolutionDetails = resolutionEvent.Details,
ElapsedSinceDecision = elapsed
};
}
/// <summary>
/// Records an outcome for a decision based on user classification.
/// </summary>
public async Task<RecordedOutcome> RecordOutcomeAsync(
string memoryId,
string tenantId,
OutcomeStatus status,
string? impact = null,
string? lessons = null,
string recordedBy = "system",
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var outcome = new OutcomeRecord
{
Status = status,
ActualImpact = impact,
LessonsLearned = lessons,
RecordedBy = recordedBy,
RecordedAt = now
};
var updated = await _store.RecordOutcomeAsync(
memoryId,
tenantId,
outcome,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Outcome recorded for decision {MemoryId}: {Status}",
memoryId,
status);
return new RecordedOutcome
{
MemoryId = memoryId,
TenantId = tenantId,
Status = status,
RecordedAt = now,
Success = updated is not null
};
}
/// <summary>
/// Gets pending outcome prompts for a tenant.
/// </summary>
public async Task<IReadOnlyList<OutcomePrompt>> GetPendingPromptsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var cutoff = _timeProvider.GetUtcNow().AddDays(-_options.OutcomeWindowDays);
var minAge = _timeProvider.GetUtcNow().AddHours(-_options.MinHoursBeforePrompt);
var query = new OpsMemoryQuery
{
TenantId = tenantId,
Since = cutoff,
Until = minAge,
OutcomeStatus = null, // Looking for records without outcomes
PageSize = _options.MaxPendingPrompts
};
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
// Filter to only those without outcomes
var pendingDecisions = result.Items
.Where(r => !r.HasOutcome)
.ToList();
var prompts = pendingDecisions.Select(d => new OutcomePrompt
{
MemoryId = d.MemoryId,
TenantId = d.TenantId,
CveId = d.Situation.CveId,
Component = d.Situation.Component,
DecisionAction = d.Decision.Action.ToString(),
DecidedAt = d.Decision.DecidedAt,
ElapsedSinceDecision = _timeProvider.GetUtcNow() - d.Decision.DecidedAt
}).ToList();
_logger.LogDebug(
"Found {Count} pending prompts for tenant {TenantId}",
prompts.Count,
tenantId);
return prompts;
}
/// <summary>
/// Calculates success metrics for a tenant over a time period.
/// </summary>
public async Task<OutcomeMetrics> CalculateMetricsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
var query = new OpsMemoryQuery
{
TenantId = tenantId,
Since = from,
Until = to,
PageSize = 1000 // Get all for metrics calculation
};
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
var records = result.Items;
var totalDecisions = records.Count;
var withOutcomes = records.Where(r => r.HasOutcome).ToList();
var successful = withOutcomes.Count(r => r.WasSuccessful);
var resolutionTimes = withOutcomes
.Where(r => r.Outcome?.ResolutionTime.HasValue == true)
.Select(r => r.Outcome!.ResolutionTime!.Value)
.ToList();
var avgResolutionTime = resolutionTimes.Count > 0
? TimeSpan.FromTicks((long)resolutionTimes.Average(t => t.Ticks))
: TimeSpan.Zero;
var byAction = records
.GroupBy(r => r.Decision.Action)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var byOutcome = withOutcomes
.Where(r => r.Outcome is not null)
.GroupBy(r => r.Outcome!.Status)
.ToImmutableDictionary(g => g.Key, g => g.Count());
return new OutcomeMetrics
{
TenantId = tenantId,
PeriodStart = from,
PeriodEnd = to,
TotalDecisions = totalDecisions,
DecisionsWithOutcome = withOutcomes.Count,
SuccessfulOutcomes = successful,
SuccessRate = withOutcomes.Count > 0 ? (double)successful / withOutcomes.Count : 0,
AverageResolutionTime = avgResolutionTime,
ByAction = byAction,
ByOutcome = byOutcome
};
}
private static OutcomeStatus MapResolutionToOutcome(string resolutionType, DecisionAction action)
{
return resolutionType.ToUpperInvariant() switch
{
"UPGRADED" => OutcomeStatus.Success,
"PATCHED" => OutcomeStatus.Success,
"REMOVED" => OutcomeStatus.Success,
"MITIGATED" => OutcomeStatus.PartialSuccess,
"FALSE_POSITIVE" => OutcomeStatus.Success,
"WONT_FIX" => action == DecisionAction.Accept ? OutcomeStatus.Success : OutcomeStatus.PartialSuccess,
"EXPLOITED" => OutcomeStatus.NegativeOutcome,
"INCIDENT" => OutcomeStatus.NegativeOutcome,
_ => OutcomeStatus.Pending
};
}
}
/// <summary>
/// Interface for outcome tracking.
/// </summary>
public interface IOutcomeTrackingService
{
/// <summary>Detects resolution and creates outcome prompt.</summary>
Task<OutcomePrompt?> DetectResolutionAsync(ResolutionEvent resolutionEvent, CancellationToken cancellationToken = default);
/// <summary>Records an outcome for a decision.</summary>
Task<RecordedOutcome> RecordOutcomeAsync(
string memoryId,
string tenantId,
OutcomeStatus status,
string? impact = null,
string? lessons = null,
string recordedBy = "system",
CancellationToken cancellationToken = default);
/// <summary>Gets pending outcome prompts.</summary>
Task<IReadOnlyList<OutcomePrompt>> GetPendingPromptsAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>Calculates success metrics.</summary>
Task<OutcomeMetrics> CalculateMetricsAsync(string tenantId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default);
}
/// <summary>
/// A resolution event indicating a finding was resolved.
/// </summary>
public sealed record ResolutionEvent
{
/// <summary>Gets the tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Gets the CVE ID.</summary>
public required string CveId { get; init; }
/// <summary>Gets the component PURL.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Gets when the resolution occurred.</summary>
public required DateTimeOffset ResolvedAt { get; init; }
/// <summary>Gets the resolution type.</summary>
public required string ResolutionType { get; init; }
/// <summary>Gets additional details.</summary>
public string? Details { get; init; }
}
/// <summary>
/// Prompt for recording an outcome.
/// </summary>
public sealed record OutcomePrompt
{
/// <summary>Gets the memory ID.</summary>
public required string MemoryId { get; init; }
/// <summary>Gets the tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Gets the CVE ID.</summary>
public string? CveId { get; init; }
/// <summary>Gets the component.</summary>
public string? Component { get; init; }
/// <summary>Gets the decision action taken.</summary>
public required string DecisionAction { get; init; }
/// <summary>Gets when the decision was made.</summary>
public required DateTimeOffset DecidedAt { get; init; }
/// <summary>Gets the suggested outcome status.</summary>
public OutcomeStatus? SuggestedOutcome { get; init; }
/// <summary>Gets resolution details.</summary>
public string? ResolutionDetails { get; init; }
/// <summary>Gets elapsed time since decision.</summary>
public TimeSpan? ElapsedSinceDecision { get; init; }
}
/// <summary>
/// Recorded outcome result.
/// </summary>
public sealed record RecordedOutcome
{
/// <summary>Gets the memory ID.</summary>
public required string MemoryId { get; init; }
/// <summary>Gets the tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Gets the outcome status.</summary>
public required OutcomeStatus Status { get; init; }
/// <summary>Gets when recorded.</summary>
public required DateTimeOffset RecordedAt { get; init; }
/// <summary>Gets whether recording succeeded.</summary>
public bool Success { get; init; }
}
/// <summary>
/// Success metrics for outcomes.
/// </summary>
public sealed record OutcomeMetrics
{
/// <summary>Gets the tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Gets the period start.</summary>
public required DateTimeOffset PeriodStart { get; init; }
/// <summary>Gets the period end.</summary>
public required DateTimeOffset PeriodEnd { get; init; }
/// <summary>Gets total decisions.</summary>
public int TotalDecisions { get; init; }
/// <summary>Gets decisions with outcomes.</summary>
public int DecisionsWithOutcome { get; init; }
/// <summary>Gets successful outcomes.</summary>
public int SuccessfulOutcomes { get; init; }
/// <summary>Gets success rate (0-1).</summary>
public double SuccessRate { get; init; }
/// <summary>Gets average resolution time.</summary>
public TimeSpan AverageResolutionTime { get; init; }
/// <summary>Gets counts by action.</summary>
public ImmutableDictionary<DecisionAction, int> ByAction { get; init; } =
ImmutableDictionary<DecisionAction, int>.Empty;
/// <summary>Gets counts by outcome status.</summary>
public ImmutableDictionary<OutcomeStatus, int> ByOutcome { get; init; } =
ImmutableDictionary<OutcomeStatus, int>.Empty;
}
/// <summary>
/// Options for outcome tracking.
/// </summary>
public sealed class OutcomeTrackingOptions
{
/// <summary>
/// Gets or sets the window in days to look back for outcomes.
/// Default: 30 days.
/// </summary>
public int OutcomeWindowDays { get; set; } = 30;
/// <summary>
/// Gets or sets minimum hours before prompting for outcome.
/// Default: 24 hours.
/// </summary>
public int MinHoursBeforePrompt { get; set; } = 24;
/// <summary>
/// Gets or sets maximum pending prompts to return.
/// Default: 50.
/// </summary>
public int MaxPendingPrompts { get; set; } = 50;
}

View File

@@ -0,0 +1,273 @@
// <copyright file="PostgresOpsMemoryStoreTests.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 Npgsql;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Storage;
using Xunit;
namespace StellaOps.OpsMemory.Tests.Integration;
/// <summary>
/// Integration tests for PostgresOpsMemoryStore.
/// Uses the CI PostgreSQL instance on port 5433.
/// </summary>
[Trait("Category", "Integration")]
public sealed class PostgresOpsMemoryStoreTests : IAsyncLifetime
{
private const string ConnectionString = "Host=localhost;Port=5433;Database=stellaops_test;Username=stellaops_ci;Password=ci_test_password";
private NpgsqlDataSource? _dataSource;
private PostgresOpsMemoryStore? _store;
public async ValueTask InitializeAsync()
{
_dataSource = NpgsqlDataSource.Create(ConnectionString);
_store = new PostgresOpsMemoryStore(
_dataSource,
NullLogger<PostgresOpsMemoryStore>.Instance);
// Clean up any existing test data
await using var cmd = _dataSource.CreateCommand("DELETE FROM opsmemory.decisions WHERE tenant_id LIKE 'test-%'");
await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
public async ValueTask DisposeAsync()
{
if (_store != null)
{
await _store.DisposeAsync();
}
if (_dataSource != null)
{
await _dataSource.DisposeAsync();
}
}
[Fact]
public async Task RecordDecision_ShouldPersistAndRetrieve()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var tenantId = $"test-{Guid.NewGuid()}";
var memoryId = Guid.NewGuid().ToString();
var now = DateTimeOffset.UtcNow;
var contextTags = ImmutableArray.Create("production", "critical-path");
var similarityVector = ImmutableArray.CreateRange(Enumerable.Range(0, 50).Select(i => (float)i / 100));
var record = new OpsMemoryRecord
{
MemoryId = memoryId,
TenantId = tenantId,
RecordedAt = now,
Situation = new SituationContext
{
CveId = "CVE-2024-1234",
Component = "pkg:npm/lodash@4.17.20",
ComponentName = "lodash",
ComponentVersion = "4.17.20",
Severity = "HIGH",
CvssScore = 7.5,
Reachability = ReachabilityStatus.Reachable,
EpssScore = 0.15,
IsKev = false,
ContextTags = contextTags
},
Decision = new DecisionRecord
{
Action = DecisionAction.Defer,
Rationale = "Waiting for upstream patch",
DecidedBy = "security-team",
DecidedAt = now
},
SimilarityVector = similarityVector
};
// Act
await _store!.RecordDecisionAsync(record, ct);
var retrieved = await _store.GetByIdAsync(memoryId, tenantId, ct);
// Assert
retrieved.Should().NotBeNull();
retrieved!.MemoryId.Should().Be(memoryId);
retrieved.TenantId.Should().Be(tenantId);
retrieved.Situation.CveId.Should().Be("CVE-2024-1234");
retrieved.Situation.Component.Should().Be("pkg:npm/lodash@4.17.20");
retrieved.Situation.Severity.Should().Be("HIGH");
retrieved.Situation.CvssScore.Should().Be(7.5);
retrieved.Situation.Reachability.Should().Be(ReachabilityStatus.Reachable);
retrieved.Decision.Action.Should().Be(DecisionAction.Defer);
retrieved.Decision.Rationale.Should().Be("Waiting for upstream patch");
retrieved.Situation.ContextTags.Should().Contain("production");
retrieved.SimilarityVector.Should().HaveCount(50);
}
[Fact]
public async Task RecordOutcome_ShouldUpdateDecision()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var tenantId = $"test-{Guid.NewGuid()}";
var memoryId = Guid.NewGuid().ToString();
var now = DateTimeOffset.UtcNow;
var record = new OpsMemoryRecord
{
MemoryId = memoryId,
TenantId = tenantId,
RecordedAt = now,
Situation = new SituationContext
{
CveId = "CVE-2024-5678",
Reachability = ReachabilityStatus.Unknown
},
Decision = new DecisionRecord
{
Action = DecisionAction.Remediate,
Rationale = "Critical finding requires fix",
DecidedBy = "auto-policy",
DecidedAt = now
}
};
await _store!.RecordDecisionAsync(record, ct);
var outcome = new OutcomeRecord
{
Status = OutcomeStatus.Success,
ResolutionTime = TimeSpan.FromDays(3),
ActualImpact = "None - patched before exploitation",
LessonsLearned = "Auto-remediation worked well",
RecordedBy = "security-team",
RecordedAt = now.AddDays(3),
WouldRepeat = true
};
// Act
var updated = await _store.RecordOutcomeAsync(memoryId, tenantId, outcome, ct);
// Assert
updated.Should().NotBeNull();
updated!.HasOutcome.Should().BeTrue();
updated.Outcome.Should().NotBeNull();
updated.Outcome!.Status.Should().Be(OutcomeStatus.Success);
updated.Outcome.ResolutionTime.Should().Be(TimeSpan.FromDays(3));
updated.Outcome.ActualImpact.Should().Be("None - patched before exploitation");
updated.WasSuccessful.Should().BeTrue();
}
[Fact]
public async Task Query_ShouldFilterByTenant()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var tenant1 = $"test-{Guid.NewGuid()}";
var tenant2 = $"test-{Guid.NewGuid()}";
var now = DateTimeOffset.UtcNow;
var record1 = CreateRecord(tenant1, "CVE-T1-001", DecisionAction.Accept, now);
var record2 = CreateRecord(tenant2, "CVE-T2-001", DecisionAction.Defer, now);
await _store!.RecordDecisionAsync(record1, ct);
await _store.RecordDecisionAsync(record2, ct);
// Act
var result = await _store.QueryAsync(new OpsMemoryQuery { TenantId = tenant1 }, ct);
// Assert
result.Items.Should().HaveCount(1);
result.Items[0].Situation.CveId.Should().Be("CVE-T1-001");
}
[Fact]
public async Task Query_ShouldFilterByCve()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var tenantId = $"test-{Guid.NewGuid()}";
var now = DateTimeOffset.UtcNow;
var record1 = CreateRecord(tenantId, "CVE-2024-1111", DecisionAction.Accept, now);
var record2 = CreateRecord(tenantId, "CVE-2024-2222", DecisionAction.Defer, now);
await _store!.RecordDecisionAsync(record1, ct);
await _store.RecordDecisionAsync(record2, ct);
// Act
var result = await _store.QueryAsync(new OpsMemoryQuery
{
TenantId = tenantId,
CveId = "CVE-2024-1111"
}, ct);
// Assert
result.Items.Should().HaveCount(1);
result.Items[0].Decision.Action.Should().Be(DecisionAction.Accept);
}
[Fact]
public async Task GetStats_ShouldReturnCorrectCounts()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var tenantId = $"test-{Guid.NewGuid()}";
var now = DateTimeOffset.UtcNow;
var record1 = CreateRecord(tenantId, "CVE-STAT-001", DecisionAction.Remediate, now);
var record2 = CreateRecord(tenantId, "CVE-STAT-002", DecisionAction.Defer, now);
await _store!.RecordDecisionAsync(record1, ct);
await _store.RecordDecisionAsync(record2, ct);
// Record outcome for one
await _store.RecordOutcomeAsync(record1.MemoryId, tenantId, new OutcomeRecord
{
Status = OutcomeStatus.Success,
RecordedBy = "test",
RecordedAt = now
}, ct);
// Act
var stats = await _store.GetStatsAsync(tenantId, null, ct);
// Assert
stats.TotalDecisions.Should().Be(2);
stats.DecisionsWithOutcomes.Should().Be(1);
stats.SuccessRate.Should().Be(1.0);
stats.ByAction.Should().ContainKey(DecisionAction.Remediate);
stats.ByAction.Should().ContainKey(DecisionAction.Defer);
}
private static OpsMemoryRecord CreateRecord(string tenantId, string cveId, DecisionAction action, DateTimeOffset at)
{
return new OpsMemoryRecord
{
MemoryId = Guid.NewGuid().ToString(),
TenantId = tenantId,
RecordedAt = at,
Situation = new SituationContext
{
CveId = cveId,
Reachability = ReachabilityStatus.Unknown
},
Decision = new DecisionRecord
{
Action = action,
Rationale = $"Test decision for {cveId}",
DecidedBy = "test",
DecidedAt = at
}
};
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Moq" />
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,345 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Similarity;
using Xunit;
namespace StellaOps.OpsMemory.Tests.Unit;
/// <summary>
/// Unit tests for SimilarityVectorGenerator.
/// Sprint: SPRINT_20260107_006_004 Task OM-010
/// </summary>
[Trait("Category", "Unit")]
public class SimilarityVectorGeneratorTests
{
private readonly SimilarityVectorGenerator _generator = new();
[Fact]
public void Generate_WithEmptySituation_SetsReachabilityUnknownDimension()
{
// Arrange
var situation = new SituationContext();
// Act
var vector = _generator.Generate(situation);
// Assert
vector.Should().HaveCount(50);
// Reachability defaults to Unknown (dimension 15)
vector[15].Should().BeGreaterThan(0, "reachability Unknown should be set");
}
[Fact]
public void Generate_WithSeverity_SetsCorrectDimension()
{
// Arrange
var lowSeverity = new SituationContext { Severity = "low" };
var criticalSeverity = new SituationContext { Severity = "critical" };
// Act
var lowVector = _generator.Generate(lowSeverity);
var criticalVector = _generator.Generate(criticalSeverity);
// Assert
// Severity dimensions are 10-14 (none, low, medium, high, critical)
lowVector[11].Should().BeGreaterThan(0, "low severity should set dimension 11");
criticalVector[14].Should().BeGreaterThan(0, "critical severity should set dimension 14");
}
[Fact]
public void Generate_WithReachability_SetsCorrectDimension()
{
// Arrange
var reachable = new SituationContext { Reachability = ReachabilityStatus.Reachable };
var notReachable = new SituationContext { Reachability = ReachabilityStatus.NotReachable };
// Act
var reachableVector = _generator.Generate(reachable);
var notReachableVector = _generator.Generate(notReachable);
// Assert
// Reachability dimensions are 15-18 (unknown=0, reachable=1, notReachable=2, potential=3)
reachableVector[16].Should().BeGreaterThan(0, "reachable should set dimension 16");
notReachableVector[17].Should().BeGreaterThan(0, "not reachable should set dimension 17");
}
[Fact]
public void Generate_WithEpssScore_SetsBandDimension()
{
// Arrange
var lowEpss = new SituationContext { EpssScore = 0.1 }; // Band 0-0.2 -> dim 19
var highEpss = new SituationContext { EpssScore = 0.9 }; // Band 0.8-1.0 -> dim 23
// Act
var lowVector = _generator.Generate(lowEpss);
var highVector = _generator.Generate(highEpss);
// Assert
lowVector[19].Should().BeGreaterThan(0, "low EPSS should set dimension 19");
highVector[23].Should().BeGreaterThan(0, "high EPSS should set dimension 23");
}
[Fact]
public void Generate_WithCvssScore_SetsBandDimension()
{
// Arrange
var lowCvss = new SituationContext { CvssScore = 1.5 }; // Band 0-2 -> dim 24
var highCvss = new SituationContext { CvssScore = 9.5 }; // Band 8-10 -> dim 28
// Act
var lowVector = _generator.Generate(lowCvss);
var highVector = _generator.Generate(highCvss);
// Assert
lowVector[24].Should().BeGreaterThan(0, "low CVSS should set dimension 24");
highVector[28].Should().BeGreaterThan(0, "high CVSS should set dimension 28");
}
[Fact]
public void Generate_WithKev_SetsKevDimension()
{
// Arrange
var kev = new SituationContext { IsKev = true };
var notKev = new SituationContext { IsKev = false };
// Act
var kevVector = _generator.Generate(kev);
var notKevVector = _generator.Generate(notKev);
// Assert
kevVector[29].Should().BeGreaterThan(0, "KEV should set dimension 29");
notKevVector[29].Should().Be(0, "non-KEV should not set dimension 29");
}
[Fact]
public void Generate_WithNpmComponent_SetsComponentTypeDimension()
{
// Arrange
var npm = new SituationContext { Component = "pkg:npm/lodash@4.17.21" };
// Act
var vector = _generator.Generate(npm);
// Assert
// Component type dimensions are 30-39, npm is index 0
vector[30].Should().BeGreaterThan(0, "npm component should set dimension 30");
}
[Fact]
public void Generate_WithContextTags_SetsTagDimensions()
{
// Arrange
var situation = new SituationContext
{
ContextTags = ImmutableArray.Create("production", "external-facing", "payment")
};
// Act
var vector = _generator.Generate(situation);
// Assert
// Context tag dimensions are 40-49
// production=0, external-facing=3, payment=5
vector[40].Should().BeGreaterThan(0, "production tag should set dimension 40");
vector[43].Should().BeGreaterThan(0, "external-facing tag should set dimension 43");
vector[45].Should().BeGreaterThan(0, "payment tag should set dimension 45");
}
[Fact]
public void Generate_VectorIsNormalized()
{
// Arrange
var situation = new SituationContext
{
Severity = "high",
Reachability = ReachabilityStatus.Reachable,
EpssScore = 0.5,
CvssScore = 7.5,
IsKev = true,
ContextTags = ImmutableArray.Create("production", "payment")
};
// Act
var vector = _generator.Generate(situation);
// Assert - L2 norm should be approximately 1 (unit vector)
var norm = Math.Sqrt(vector.Sum(v => v * v));
norm.Should().BeApproximately(1.0, 0.001, "vector should be normalized to unit length");
}
[Fact]
public void CosineSimilarity_IdenticalVectors_ReturnsOne()
{
// Arrange
var situation = new SituationContext
{
Severity = "high",
Reachability = ReachabilityStatus.Reachable
};
var vector = _generator.Generate(situation);
// Act
var similarity = SimilarityVectorGenerator.CosineSimilarity(vector, vector);
// Assert
similarity.Should().BeApproximately(1.0, 0.001, "identical vectors should have similarity 1");
}
[Fact]
public void CosineSimilarity_DifferentSituations_ReturnsLowerThanIdentical()
{
// Arrange - create two different vectors
var situation1 = new SituationContext
{
Severity = "low",
Reachability = ReachabilityStatus.Reachable,
EpssScore = 0.1
};
var situation2 = new SituationContext
{
Severity = "critical",
Reachability = ReachabilityStatus.NotReachable,
EpssScore = 0.9
};
var vector1 = _generator.Generate(situation1);
var vector2 = _generator.Generate(situation2);
// Act
var sameSimilarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector1);
var diffSimilarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
// Assert - different situations should have lower similarity than identical
diffSimilarity.Should().BeLessThan(sameSimilarity, "different situations should have lower similarity than identical");
diffSimilarity.Should().BeGreaterThanOrEqualTo(0, "similarity should be non-negative");
}
[Fact]
public void CosineSimilarity_SimilarSituations_ReturnsHighValue()
{
// Arrange
var situation1 = new SituationContext
{
Severity = "high",
Reachability = ReachabilityStatus.Reachable,
EpssScore = 0.75,
IsKev = true
};
var situation2 = new SituationContext
{
Severity = "high",
Reachability = ReachabilityStatus.Reachable,
EpssScore = 0.8, // Same band as 0.75
IsKev = true
};
var vector1 = _generator.Generate(situation1);
var vector2 = _generator.Generate(situation2);
// Act
var similarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
// Assert - 0.8+ is still quite similar for real-world situations
similarity.Should().BeGreaterThan(0.8, "similar situations should have high similarity");
}
[Fact]
public void CosineSimilarity_DifferentLengthVectors_ThrowsArgumentException()
{
// Arrange
var vector1 = ImmutableArray.Create(1.0f, 0.0f, 0.0f);
var vector2 = ImmutableArray.Create(1.0f, 0.0f);
// Act & Assert
Action act = () => SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
act.Should().Throw<ArgumentException>()
.WithMessage("*same dimension*");
}
[Fact]
public void GetMatchingFactors_SameSeverity_ReportsMatch()
{
// Arrange
var a = new SituationContext { Severity = "high" };
var b = new SituationContext { Severity = "high" };
// Act
var factors = _generator.GetMatchingFactors(a, b);
// Assert
factors.Should().Contain(f => f.Contains("severity", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetMatchingFactors_SameReachability_ReportsMatch()
{
// Arrange
var a = new SituationContext { Reachability = ReachabilityStatus.Reachable };
var b = new SituationContext { Reachability = ReachabilityStatus.Reachable };
// Act
var factors = _generator.GetMatchingFactors(a, b);
// Assert
factors.Should().Contain(f => f.Contains("reachability", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetMatchingFactors_BothKev_ReportsMatch()
{
// Arrange
var a = new SituationContext { IsKev = true };
var b = new SituationContext { IsKev = true };
// Act
var factors = _generator.GetMatchingFactors(a, b);
// Assert
factors.Should().Contain(f => f.Contains("KEV", StringComparison.Ordinal));
}
[Fact]
public void GetMatchingFactors_OverlappingTags_ReportsMatch()
{
// Arrange
var a = new SituationContext { ContextTags = ImmutableArray.Create("production", "api") };
var b = new SituationContext { ContextTags = ImmutableArray.Create("production", "frontend") };
// Act
var factors = _generator.GetMatchingFactors(a, b);
// Assert
factors.Should().Contain(f => f.Contains("production", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetMatchingFactors_SimilarEpss_ReportsMatch()
{
// Arrange
var a = new SituationContext { EpssScore = 0.75 };
var b = new SituationContext { EpssScore = 0.80 }; // Within 0.2 threshold
// Act
var factors = _generator.GetMatchingFactors(a, b);
// Assert
factors.Should().Contain(f => f.Contains("EPSS", StringComparison.Ordinal));
}
[Fact]
public void GetMatchingFactors_DifferentEpss_DoesNotReportMatch()
{
// Arrange
var a = new SituationContext { EpssScore = 0.2 };
var b = new SituationContext { EpssScore = 0.8 }; // More than 0.2 difference
// Act
var factors = _generator.GetMatchingFactors(a, b);
// Assert
factors.Should().NotContain(f => f.Contains("EPSS", StringComparison.Ordinal));
}
}