consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -3,5 +3,5 @@
"advisoryai.validation.q_required": "q ist erforderlich.",
"advisoryai.validation.q_max_512": "q darf maximal 512 Zeichen lang sein.",
"advisoryai.validation.tenant_required": "Tenant-Kontext ist erforderlich."
"advisoryai.validation.tenant_required": "Mandantenkontext ist erforderlich."
}

File diff suppressed because it is too large Load Diff

View File

@@ -319,7 +319,7 @@
"sha256": "1be0ec1ce56cd616259095ccbfce106121c75e4d40621fbe2b1f022ff76072fe"
},
{
"path": "docs/modules/cli/guides/commands/orchestrator.md",
"path": "docs/modules/cli/guides/commands/jobengine.md",
"sha256": "5e74b92d1615f8300765ed156ed709c70645ad95f67b22f43bc47cc10589de30"
},
{
@@ -1583,7 +1583,7 @@
"sha256": "fa50d45dc2b02d2f89a12d801d38c83e3d89070caa318575123da54db9ea48c5"
},
{
"path": "docs/operations/orchestrator-runbook.md",
"path": "docs/operations/jobengine-runbook.md",
"sha256": "64af4dd5bda8eebb2e9323e2bf7ef8308b0dd2e2ba33a11bae20222f4945c247"
},
{
@@ -1715,23 +1715,23 @@
"sha256": "a1a31a4c8baf091f67e3a5b043118ee93a05c5314fb3eb1c3b6fd14e53c19d96"
},
{
"path": "docs/operations/runbooks/orchestrator-evidence-missing.md",
"path": "docs/operations/runbooks/jobengine-evidence-missing.md",
"sha256": "a180683a2de5a3fe60ae6477c1de7c5b36e37ad189c06373109c4afebe58b6da"
},
{
"path": "docs/operations/runbooks/orchestrator-gate-timeout.md",
"path": "docs/operations/runbooks/jobengine-gate-timeout.md",
"sha256": "3695ac55be0a165a4ca2052fa328f0dc312e20194cafeda8b3a9bda7ac599e76"
},
{
"path": "docs/operations/runbooks/orchestrator-promotion-stuck.md",
"path": "docs/operations/runbooks/jobengine-promotion-stuck.md",
"sha256": "bd5f464a941808d9bdb9a80d488c2885b3a9282fe4f7b05402e2b1766c22d276"
},
{
"path": "docs/operations/runbooks/orchestrator-quota-exceeded.md",
"path": "docs/operations/runbooks/jobengine-quota-exceeded.md",
"sha256": "1cfcaef596e6d75e54545c1e41bc310f5858f67f0a0800a0087ba92a7471d567"
},
{
"path": "docs/operations/runbooks/orchestrator-rollback-failed.md",
"path": "docs/operations/runbooks/jobengine-rollback-failed.md",
"sha256": "35abf5027af2e64060137fa9dbb23f500002c57ecea8ec762fa6fce1d4711073"
},
{

View File

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

View File

@@ -48,7 +48,7 @@ internal sealed class PlatformCatalogIngestionAdapter : ISearchIngestionAdapter
EntityType: "pack",
Title: "Pack: Offline Kit",
Summary: "Offline kit export bundle",
Source: "orchestrator",
Source: "jobengine",
Route: "/packs/offline-kit"),
new PlatformCatalogEntry(
EntityId: "tenant-acme",

View File

@@ -0,0 +1,570 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Determinism;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Storage;
using StellaOps.OpsMemory.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Collections.Immutable;
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")
.RequireAuthorization(OpsMemoryPolicies.Read)
.RequireTenant();
group.MapPost("/decisions", RecordDecisionAsync)
.WithName("RecordDecision")
.WithDescription("Records a security decision (accept, suppress, mitigate, escalate) for a CVE and component combination. The decision is stored with situational context for future playbook learning and similarity matching. Returns 201 Created with the new memory ID.")
.RequireAuthorization(OpsMemoryPolicies.Write);
group.MapGet("/decisions/{memoryId}", GetDecisionAsync)
.WithName("GetDecision")
.WithDescription("Returns the full decision record for a specific memory ID including situational context, decision details, mitigation information, and outcome if recorded. Returns 404 if not found.");
group.MapPost("/decisions/{memoryId}/outcome", RecordOutcomeAsync)
.WithName("RecordOutcome")
.WithDescription("Records the observed outcome of a previously stored security decision, capturing resolution time, actual impact, lessons learned, and whether the decision would be repeated. Returns 200 with the updated memory ID.")
.RequireAuthorization(OpsMemoryPolicies.Write);
group.MapGet("/suggestions", GetSuggestionsAsync)
.WithName("GetPlaybookSuggestions")
.WithDescription("Returns ranked playbook suggestions for a given situational context by matching against historical decisions using similarity scoring. Each suggestion includes confidence, success rate, and evidence from past decisions.");
group.MapGet("/decisions", QueryDecisionsAsync)
.WithName("QueryDecisions")
.WithDescription("Queries stored security decisions with optional filters by CVE ID, component prefix, action type, and outcome status. Supports cursor-based pagination.");
group.MapGet("/stats", GetStatsAsync)
.WithName("GetOpsMemoryStats")
.WithDescription("Returns aggregated decision statistics for the tenant including total decision count, decisions with recorded outcomes, and overall success rate.");
}
/// <summary>
/// Record a new security decision.
/// </summary>
private static async Task<Results<Created<RecordDecisionResponse>, BadRequest<ProblemDetails>>> RecordDecisionAsync(
RecordDecisionRequest request,
IOpsMemoryStore store,
TimeProvider timeProvider,
IGuidProvider guidProvider,
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 = guidProvider.NewGuid().ToString("N"),
TenantId = request.TenantId,
RecordedAt = timeProvider.GetUtcNow(),
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 = timeProvider.GetUtcNow(),
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,
TimeProvider timeProvider,
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 = timeProvider.GetUtcNow()
};
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,110 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using Npgsql;
using StellaOps.Determinism;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using StellaOps.Localization;
using StellaOps.Router.AspNet;
using StellaOps.OpsMemory.WebService.Endpoints;
using StellaOps.OpsMemory.WebService.Security;
var builder = WebApplication.CreateBuilder(args);
// Add PostgreSQL data source
var connectionString = ResolveOpsMemoryConnectionString(builder);
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
// Add determinism abstractions (TimeProvider + IGuidProvider for endpoint parameter binding)
builder.Services.AddDeterminismDefaults();
// 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();
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(OpsMemoryPolicies.Read, StellaOpsScopes.OpsMemoryRead);
options.AddStellaOpsScopePolicy(OpsMemoryPolicies.Write, StellaOpsScopes.OpsMemoryWrite);
});
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "opsmemory",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("opsmemory");
var app = builder.Build();
app.LogStellaOpsLocalHostname("opsmemory");
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
// Map endpoints
app.MapOpsMemoryEndpoints();
app.MapHealthChecks("/health");
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.LoadTranslationsAsync();
app.Run();
static string ResolveOpsMemoryConnectionString(WebApplicationBuilder builder)
{
// Explicit service connection has priority; shared default is the compose-compatible fallback.
var configuredConnectionString =
builder.Configuration.GetConnectionString("OpsMemory")
?? builder.Configuration["ConnectionStrings:OpsMemory"]
?? builder.Configuration.GetConnectionString("Default")
?? builder.Configuration["ConnectionStrings:Default"];
if (!string.IsNullOrWhiteSpace(configuredConnectionString))
{
return configuredConnectionString.Trim();
}
if (builder.Environment.IsDevelopment())
{
return "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
}
throw new InvalidOperationException(
"OpsMemory database connection string is required in non-development environments. Configure ConnectionStrings:OpsMemory or ConnectionStrings:Default.");
}

View File

@@ -0,0 +1,14 @@
{
"profiles": {
"StellaOps.OpsMemory.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
},
"applicationUrl": "https://localhost:10270;http://localhost:10271"
}
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.OpsMemory.WebService.Security;
/// <summary>
/// Named authorization policy constants for the OpsMemory service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class OpsMemoryPolicies
{
/// <summary>Policy for reading decisions and suggestions. Requires ops-memory:read scope.</summary>
public const string Read = "OpsMemory.Read";
/// <summary>Policy for recording decisions and outcomes. Requires ops-memory:write scope.</summary>
public const string Write = "OpsMemory.Write";
}

View File

@@ -0,0 +1,31 @@
<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="..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Npgsql" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,9 @@
# StellaOps.OpsMemory.WebService Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| S312-OPSMEMORY-CONNECTION | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` TASK-312-007: aligned connection resolution with compose defaults (`ConnectionStrings:Default` fallback) and added fail-fast behavior for non-development when DB config is missing. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,3 @@
{
"_meta": { "locale": "en-US", "namespace": "opsmemory", "version": "1.0" }
}

View File

@@ -0,0 +1,364 @@
// <copyright file="IOpsMemoryChatProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Models;
using System.Collections.Immutable;
namespace StellaOps.OpsMemory.Integration;
/// <summary>
/// Provider for integrating OpsMemory with chat-based AI advisors.
/// Enables surfacing past decisions in chat context and recording new decisions from chat actions.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-001
/// </summary>
public interface IOpsMemoryChatProvider
{
/// <summary>
/// Enriches chat context with relevant past decisions and playbook suggestions.
/// </summary>
/// <param name="request">The chat context request with situational information.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpsMemory context with similar decisions and applicable tactics.</returns>
Task<OpsMemoryContext> EnrichContextAsync(
ChatContextRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Records a decision from an executed chat action.
/// </summary>
/// <param name="action">The action execution result from chat.</param>
/// <param name="context">The conversation context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The recorded OpsMemory record.</returns>
Task<OpsMemoryRecord> RecordFromActionAsync(
ActionExecutionResult action,
ConversationContext context,
CancellationToken cancellationToken);
/// <summary>
/// Gets recent decisions for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="limit">Maximum number of decisions to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Recent decision summaries.</returns>
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsAsync(
string tenantId,
int limit,
CancellationToken cancellationToken);
}
/// <summary>
/// Request for chat context enrichment from OpsMemory.
/// </summary>
public sealed record ChatContextRequest
{
/// <summary>
/// Gets the tenant identifier for isolation.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the CVE identifier being discussed (if any).
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Gets the component PURL being discussed.
/// </summary>
public string? Component { get; init; }
/// <summary>
/// Gets the severity level (Critical, High, Medium, Low).
/// </summary>
public string? Severity { get; init; }
/// <summary>
/// Gets the reachability status.
/// </summary>
public ReachabilityStatus? Reachability { get; init; }
/// <summary>
/// Gets the CVSS score (0-10).
/// </summary>
public double? CvssScore { get; init; }
/// <summary>
/// Gets the EPSS score (0-1).
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// Gets additional context tags (environment, team, etc.).
/// </summary>
public ImmutableArray<string> ContextTags { get; init; } = [];
/// <summary>
/// Gets the maximum number of similar decisions to return.
/// </summary>
public int MaxSuggestions { get; init; } = 3;
/// <summary>
/// Gets the minimum similarity score for matches (0-1).
/// </summary>
public double MinSimilarity { get; init; } = 0.6;
}
/// <summary>
/// Context from OpsMemory to enrich chat responses.
/// </summary>
public sealed record OpsMemoryContext
{
/// <summary>
/// Gets similar past decisions with their outcomes.
/// </summary>
public ImmutableArray<PastDecisionSummary> SimilarDecisions { get; init; } = [];
/// <summary>
/// Gets relevant known issues from the corpus.
/// </summary>
public ImmutableArray<KnownIssue> RelevantKnownIssues { get; init; } = [];
/// <summary>
/// Gets applicable tactics based on the situation.
/// </summary>
public ImmutableArray<Tactic> ApplicableTactics { get; init; } = [];
/// <summary>
/// Gets the generated prompt segment for the AI.
/// </summary>
public string? PromptSegment { get; init; }
/// <summary>
/// Gets the total number of similar situations found.
/// </summary>
public int TotalSimilarCount { get; init; }
/// <summary>
/// Gets whether there are applicable playbook entries.
/// </summary>
public bool HasPlaybookEntries => SimilarDecisions.Length > 0 || ApplicableTactics.Length > 0;
}
/// <summary>
/// Summary of a past decision for chat context.
/// </summary>
public sealed record PastDecisionSummary
{
/// <summary>
/// Gets the memory record ID.
/// </summary>
public required string MemoryId { get; init; }
/// <summary>
/// Gets the CVE ID (if any).
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Gets the component affected.
/// </summary>
public string? Component { get; init; }
/// <summary>
/// Gets the severity at the time of decision.
/// </summary>
public string? Severity { get; init; }
/// <summary>
/// Gets the action that was taken.
/// </summary>
public required DecisionAction Action { get; init; }
/// <summary>
/// Gets the rationale for the decision.
/// </summary>
public string? Rationale { get; init; }
/// <summary>
/// Gets the outcome status (if recorded).
/// </summary>
public OutcomeStatus? OutcomeStatus { get; init; }
/// <summary>
/// Gets the similarity score to the current situation (0-1).
/// </summary>
public double SimilarityScore { get; init; }
/// <summary>
/// Gets when the decision was made.
/// </summary>
public DateTimeOffset DecidedAt { get; init; }
/// <summary>
/// Gets any lessons learned from the outcome.
/// </summary>
public string? LessonsLearned { get; init; }
}
/// <summary>
/// A known issue from the corpus.
/// </summary>
public sealed record KnownIssue
{
/// <summary>
/// Gets the issue identifier.
/// </summary>
public required string IssueId { get; init; }
/// <summary>
/// Gets the issue title.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Gets the issue description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Gets the recommended action.
/// </summary>
public string? RecommendedAction { get; init; }
/// <summary>
/// Gets relevance score (0-1).
/// </summary>
public double Relevance { get; init; }
}
/// <summary>
/// A playbook tactic applicable to the situation.
/// </summary>
public sealed record Tactic
{
/// <summary>
/// Gets the tactic identifier.
/// </summary>
public required string TacticId { get; init; }
/// <summary>
/// Gets the tactic name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the tactic description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Gets applicability conditions.
/// </summary>
public ImmutableArray<string> Conditions { get; init; } = [];
/// <summary>
/// Gets the recommended action.
/// </summary>
public DecisionAction RecommendedAction { get; init; }
/// <summary>
/// Gets confidence score (0-1).
/// </summary>
public double Confidence { get; init; }
/// <summary>
/// Gets success rate from past applications.
/// </summary>
public double? SuccessRate { get; init; }
}
/// <summary>
/// Result of executing an action from chat.
/// </summary>
public sealed record ActionExecutionResult
{
/// <summary>
/// Gets the action that was executed.
/// </summary>
public required DecisionAction Action { get; init; }
/// <summary>
/// Gets the CVE ID affected.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Gets the component affected.
/// </summary>
public string? Component { get; init; }
/// <summary>
/// Gets whether the action was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Gets any error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Gets the rationale provided by the user or AI.
/// </summary>
public string? Rationale { get; init; }
/// <summary>
/// Gets the timestamp of execution.
/// </summary>
public DateTimeOffset ExecutedAt { get; init; }
/// <summary>
/// Gets the user who triggered the action.
/// </summary>
public required string ActorId { get; init; }
/// <summary>
/// Gets additional metadata about the action.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Context of the conversation where the action was taken.
/// </summary>
public sealed record ConversationContext
{
/// <summary>
/// Gets the conversation identifier.
/// </summary>
public required string ConversationId { get; init; }
/// <summary>
/// Gets the tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the user identifier.
/// </summary>
public required string UserId { get; init; }
/// <summary>
/// Gets the conversation summary/topic.
/// </summary>
public string? Topic { get; init; }
/// <summary>
/// Gets the turn number where action was taken.
/// </summary>
public int TurnNumber { get; init; }
/// <summary>
/// Gets the situation context extracted from the conversation.
/// </summary>
public SituationContext? Situation { get; init; }
/// <summary>
/// Gets any evidence links from the conversation.
/// </summary>
public ImmutableArray<string> EvidenceLinks { get; init; } = [];
}

View File

@@ -0,0 +1,360 @@
// <copyright file="OpsMemoryChatProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
namespace StellaOps.OpsMemory.Integration;
/// <summary>
/// Implementation of OpsMemory chat provider for AI integration.
/// Provides context enrichment from past decisions and records new decisions from chat actions.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-002
/// </summary>
public sealed class OpsMemoryChatProvider : IOpsMemoryChatProvider
{
private readonly IOpsMemoryStore _store;
private readonly ISimilarityVectorGenerator _vectorGenerator;
private readonly IPlaybookSuggestionService _playbookService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<OpsMemoryChatProvider> _logger;
/// <summary>
/// Creates a new OpsMemoryChatProvider.
/// </summary>
public OpsMemoryChatProvider(
IOpsMemoryStore store,
ISimilarityVectorGenerator vectorGenerator,
IPlaybookSuggestionService playbookService,
TimeProvider timeProvider,
ILogger<OpsMemoryChatProvider> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_vectorGenerator = vectorGenerator ?? throw new ArgumentNullException(nameof(vectorGenerator));
_playbookService = playbookService ?? throw new ArgumentNullException(nameof(playbookService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<OpsMemoryContext> EnrichContextAsync(
ChatContextRequest request,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Enriching chat context for tenant {TenantId}, CVE {CveId}",
request.TenantId, request.CveId ?? "(none)");
// Build situation from request
var situation = BuildSituation(request);
// Generate similarity vector for the current situation
var queryVector = _vectorGenerator.Generate(situation);
// Find similar past decisions
var similarQuery = new SimilarityQuery
{
TenantId = request.TenantId,
SimilarityVector = queryVector,
Situation = situation,
MinSimilarity = request.MinSimilarity,
Limit = request.MaxSuggestions * 2 // Fetch more to filter by outcome
};
var similarRecords = await _store.FindSimilarAsync(similarQuery, cancellationToken)
.ConfigureAwait(false);
// Convert to summaries with similarity scores, filtering out failed outcomes
var summaries = similarRecords
.Select(r => CreateSummary(r.Record, r.SimilarityScore))
.Where(s => s.OutcomeStatus is null or OutcomeStatus.Success or OutcomeStatus.PartialSuccess)
.OrderByDescending(s => s.SimilarityScore)
.ThenByDescending(s => s.OutcomeStatus == OutcomeStatus.Success ? 1 : 0)
.Take(request.MaxSuggestions)
.ToImmutableArray();
// Get applicable tactics from playbook
var tactics = await GetApplicableTacticsAsync(situation, cancellationToken).ConfigureAwait(false);
// Get known issues if CVE is provided
var knownIssues = request.CveId is not null
? await GetKnownIssuesAsync(request.CveId, cancellationToken).ConfigureAwait(false)
: ImmutableArray<KnownIssue>.Empty;
// Build prompt segment for AI
var promptSegment = BuildPromptSegment(summaries, tactics, knownIssues);
_logger.LogInformation(
"Found {DecisionCount} similar decisions, {TacticCount} applicable tactics for {CveId}",
summaries.Length, tactics.Length, request.CveId ?? "(no CVE)");
return new OpsMemoryContext
{
SimilarDecisions = summaries,
ApplicableTactics = tactics,
RelevantKnownIssues = knownIssues,
PromptSegment = promptSegment,
TotalSimilarCount = similarRecords.Count
};
}
/// <inheritdoc />
public async Task<OpsMemoryRecord> RecordFromActionAsync(
ActionExecutionResult action,
ConversationContext context,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Recording decision from chat action: {Action} for CVE {CveId}",
action.Action, action.CveId ?? "(none)");
// Build situation from conversation context
var situation = context.Situation ?? BuildSituationFromAction(action);
// Generate similarity vector
var vector = _vectorGenerator.Generate(situation);
// Create memory record
var memoryId = GenerateMemoryId();
var record = new OpsMemoryRecord
{
MemoryId = memoryId,
TenantId = context.TenantId,
RecordedAt = _timeProvider.GetUtcNow(),
Situation = situation,
Decision = new DecisionRecord
{
Action = action.Action,
Rationale = action.Rationale ?? $"Decision made via chat conversation {context.ConversationId}",
DecidedBy = action.ActorId,
DecidedAt = action.ExecutedAt,
PolicyReference = null, // Not from policy gate
VexStatementId = null,
Mitigation = null
},
Outcome = null, // Outcome tracked separately
SimilarityVector = vector
};
// Store the record
await _store.RecordDecisionAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Recorded decision {MemoryId} from chat conversation {ConversationId}",
memoryId, context.ConversationId);
return record;
}
/// <inheritdoc />
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsAsync(
string tenantId,
int limit,
CancellationToken cancellationToken)
{
var query = new OpsMemoryQuery
{
TenantId = tenantId,
PageSize = limit,
SortBy = OpsMemorySortField.RecordedAt,
Descending = true
};
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
return result.Items
.Select(r => CreateSummary(r, 0))
.ToList();
}
private static SituationContext BuildSituation(ChatContextRequest request)
{
return new SituationContext
{
CveId = request.CveId,
Component = request.Component,
Severity = request.Severity,
Reachability = request.Reachability ?? ReachabilityStatus.Unknown,
CvssScore = request.CvssScore,
EpssScore = request.EpssScore,
ContextTags = request.ContextTags
};
}
private static SituationContext BuildSituationFromAction(ActionExecutionResult action)
{
return new SituationContext
{
CveId = action.CveId,
Component = action.Component,
Reachability = ReachabilityStatus.Unknown,
ContextTags = action.Metadata.Keys
.Where(k => k.StartsWith("tag:", StringComparison.OrdinalIgnoreCase))
.Select(k => k[4..])
.ToImmutableArray()
};
}
private static PastDecisionSummary CreateSummary(OpsMemoryRecord record, double similarityScore)
{
return new PastDecisionSummary
{
MemoryId = record.MemoryId,
CveId = record.Situation.CveId,
Component = record.Situation.Component ?? record.Situation.ComponentName,
Severity = record.Situation.Severity,
Action = record.Decision.Action,
Rationale = record.Decision.Rationale,
OutcomeStatus = record.Outcome?.Status,
SimilarityScore = similarityScore,
DecidedAt = record.Decision.DecidedAt,
LessonsLearned = record.Outcome?.LessonsLearned
};
}
private async Task<ImmutableArray<Tactic>> GetApplicableTacticsAsync(
SituationContext situation,
CancellationToken cancellationToken)
{
try
{
var suggestions = await _playbookService.GetSuggestionsAsync(
situation,
maxSuggestions: 3,
cancellationToken).ConfigureAwait(false);
return suggestions
.Select(s => new Tactic
{
TacticId = $"tactic-{s.Action}",
Name = s.Action.ToString(),
Description = s.Rationale,
Conditions = s.MatchingFactors,
RecommendedAction = s.Action,
Confidence = s.Confidence,
SuccessRate = s.SuccessRate
})
.ToImmutableArray();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get playbook suggestions");
return [];
}
}
private Task<ImmutableArray<KnownIssue>> GetKnownIssuesAsync(
string cveId,
CancellationToken cancellationToken)
{
// This would integrate with a known issues database
// For now, return empty - the actual implementation would query a separate store
_ = cancellationToken;
_logger.LogDebug("Getting known issues for {CveId}", cveId);
return Task.FromResult(ImmutableArray<KnownIssue>.Empty);
}
private static string BuildPromptSegment(
ImmutableArray<PastDecisionSummary> decisions,
ImmutableArray<Tactic> tactics,
ImmutableArray<KnownIssue> issues)
{
if (decisions.Length == 0 && tactics.Length == 0 && issues.Length == 0)
{
return string.Empty;
}
var sb = new StringBuilder();
sb.AppendLine("## Previous Similar Situations (from OpsMemory)");
sb.AppendLine();
if (decisions.Length > 0)
{
sb.AppendLine("### Past Decisions");
foreach (var decision in decisions)
{
var outcomeEmoji = decision.OutcomeStatus switch
{
OutcomeStatus.Success => "[SUCCESS]",
OutcomeStatus.Failure => "[FAILED]",
OutcomeStatus.PartialSuccess => "[PARTIAL]",
_ => "[PENDING]"
};
sb.AppendLine(CultureInfo.InvariantCulture,
$"- {decision.CveId ?? "Unknown CVE"} ({decision.Severity ?? "?"} severity): " +
$"**{decision.Action}** {outcomeEmoji}");
if (!string.IsNullOrWhiteSpace(decision.Rationale))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" Rationale: {decision.Rationale}");
}
if (!string.IsNullOrWhiteSpace(decision.LessonsLearned))
{
sb.AppendLine(CultureInfo.InvariantCulture, $" Lessons: {decision.LessonsLearned}");
}
sb.AppendLine(CultureInfo.InvariantCulture,
$" Reference: [ops-mem:{decision.MemoryId}]");
}
sb.AppendLine();
}
if (tactics.Length > 0)
{
sb.AppendLine("### Applicable Playbook Tactics");
foreach (var tactic in tactics)
{
var successRate = tactic.SuccessRate.HasValue
? $" ({tactic.SuccessRate.Value:P0} success rate)"
: "";
sb.AppendLine(CultureInfo.InvariantCulture,
$"- **{tactic.Name}**: {tactic.Description}{successRate}");
if (tactic.Conditions.Length > 0)
{
sb.AppendLine(CultureInfo.InvariantCulture,
$" When: {string.Join(", ", tactic.Conditions)}");
}
}
sb.AppendLine();
}
if (issues.Length > 0)
{
sb.AppendLine("### Known Issues");
foreach (var issue in issues)
{
sb.AppendLine(CultureInfo.InvariantCulture,
$"- **{issue.Title}**: {issue.Description}");
if (!string.IsNullOrWhiteSpace(issue.RecommendedAction))
{
sb.AppendLine(CultureInfo.InvariantCulture,
$" Recommended: {issue.RecommendedAction}");
}
}
}
return sb.ToString();
}
private string GenerateMemoryId()
{
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var random = Random.Shared.Next(1000, 9999);
return $"om-chat-{timestamp}-{random}";
}
}

View File

@@ -0,0 +1,282 @@
// <copyright file="OpsMemoryContextEnricher.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Models;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
namespace StellaOps.OpsMemory.Integration;
/// <summary>
/// Enriches AI prompt context with OpsMemory data.
/// Generates structured prompt segments for past decisions and playbook tactics.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-003
/// </summary>
public sealed class OpsMemoryContextEnricher
{
private readonly IOpsMemoryChatProvider _chatProvider;
private readonly ILogger<OpsMemoryContextEnricher> _logger;
/// <summary>
/// Creates a new OpsMemoryContextEnricher.
/// </summary>
public OpsMemoryContextEnricher(
IOpsMemoryChatProvider chatProvider,
ILogger<OpsMemoryContextEnricher> logger)
{
_chatProvider = chatProvider ?? throw new ArgumentNullException(nameof(chatProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Enriches a chat prompt with OpsMemory context.
/// </summary>
/// <param name="request">The context request.</param>
/// <param name="existingPrompt">Optional existing prompt to augment.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Enriched prompt with OpsMemory context.</returns>
public async Task<EnrichedPromptResult> EnrichPromptAsync(
ChatContextRequest request,
string? existingPrompt,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Enriching prompt with OpsMemory for CVE {CveId}",
request.CveId ?? "(none)");
var context = await _chatProvider.EnrichContextAsync(request, cancellationToken)
.ConfigureAwait(false);
var systemPromptAddition = BuildSystemPromptAddition(context);
var contextBlock = BuildContextBlock(context);
var enrichedPrompt = string.IsNullOrWhiteSpace(existingPrompt)
? contextBlock
: $"{existingPrompt}\n\n{contextBlock}";
return new EnrichedPromptResult
{
EnrichedPrompt = enrichedPrompt,
SystemPromptAddition = systemPromptAddition,
Context = context,
DecisionsReferenced = context.SimilarDecisions.Select(d => d.MemoryId).ToImmutableArray(),
TacticsApplied = context.ApplicableTactics.Select(t => t.TacticId).ToImmutableArray()
};
}
/// <summary>
/// Builds a system prompt addition for OpsMemory-aware responses.
/// </summary>
public static string BuildSystemPromptAddition(OpsMemoryContext context)
{
if (!context.HasPlaybookEntries)
{
return string.Empty;
}
var sb = new StringBuilder();
sb.AppendLine("## OpsMemory Instructions");
sb.AppendLine();
sb.AppendLine("You have access to the organization's institutional decision memory (OpsMemory).");
sb.AppendLine("When providing recommendations:");
sb.AppendLine();
sb.AppendLine("1. Reference past decisions using `[ops-mem:ID]` format");
sb.AppendLine("2. Explain how past outcomes inform current recommendations");
sb.AppendLine("3. Note any lessons learned from similar situations");
sb.AppendLine("4. If a past approach failed, suggest alternatives");
sb.AppendLine("5. Include confidence levels based on historical success rates");
sb.AppendLine();
if (context.SimilarDecisions.Length > 0)
{
sb.AppendLine($"Available past decisions: {context.SimilarDecisions.Length} similar situations found.");
}
if (context.ApplicableTactics.Length > 0)
{
sb.AppendLine($"Applicable playbook tactics: {context.ApplicableTactics.Length} tactics available.");
}
return sb.ToString();
}
/// <summary>
/// Builds the context block to include in the prompt.
/// </summary>
public static string BuildContextBlock(OpsMemoryContext context)
{
if (!context.HasPlaybookEntries)
{
return string.Empty;
}
var sb = new StringBuilder();
sb.AppendLine("---");
sb.AppendLine("## Institutional Memory (OpsMemory)");
sb.AppendLine();
// Past decisions section
if (context.SimilarDecisions.Length > 0)
{
sb.AppendLine("### Similar Past Decisions");
sb.AppendLine();
foreach (var decision in context.SimilarDecisions)
{
FormatDecisionSummary(sb, decision);
}
sb.AppendLine();
}
// Applicable tactics section
if (context.ApplicableTactics.Length > 0)
{
sb.AppendLine("### Playbook Tactics");
sb.AppendLine();
foreach (var tactic in context.ApplicableTactics)
{
FormatTactic(sb, tactic);
}
sb.AppendLine();
}
// Known issues section
if (context.RelevantKnownIssues.Length > 0)
{
sb.AppendLine("### Known Issues");
sb.AppendLine();
foreach (var issue in context.RelevantKnownIssues)
{
FormatKnownIssue(sb, issue);
}
sb.AppendLine();
}
sb.AppendLine("---");
return sb.ToString();
}
private static void FormatDecisionSummary(StringBuilder sb, PastDecisionSummary decision)
{
var outcomeIndicator = decision.OutcomeStatus switch
{
OutcomeStatus.Success => "[SUCCESS]",
OutcomeStatus.Failure => "[FAILED]",
OutcomeStatus.PartialSuccess => "[PARTIAL]",
_ => "[PENDING]"
};
var similarity = decision.SimilarityScore.ToString("P0", CultureInfo.InvariantCulture);
sb.AppendLine(CultureInfo.InvariantCulture,
$"#### {decision.CveId ?? "Unknown"} - {decision.Action} {outcomeIndicator}");
sb.AppendLine();
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Similarity:** {similarity}");
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Component:** {decision.Component ?? "Unknown"}");
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Severity:** {decision.Severity ?? "?"}");
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Date:** {decision.DecidedAt:yyyy-MM-dd}");
if (!string.IsNullOrWhiteSpace(decision.Rationale))
{
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Rationale:** {decision.Rationale}");
}
if (!string.IsNullOrWhiteSpace(decision.LessonsLearned))
{
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Lessons:** {decision.LessonsLearned}");
}
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Reference:** `[ops-mem:{decision.MemoryId}]`");
sb.AppendLine();
}
private static void FormatTactic(StringBuilder sb, Tactic tactic)
{
var confidence = tactic.Confidence.ToString("P0", CultureInfo.InvariantCulture);
var successRate = tactic.SuccessRate?.ToString("P0", CultureInfo.InvariantCulture) ?? "N/A";
sb.AppendLine(CultureInfo.InvariantCulture, $"#### {tactic.Name}");
sb.AppendLine();
sb.AppendLine(CultureInfo.InvariantCulture, $"{tactic.Description}");
sb.AppendLine();
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Recommended Action:** {tactic.RecommendedAction}");
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Confidence:** {confidence}");
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Historical Success Rate:** {successRate}");
if (tactic.Conditions.Length > 0)
{
sb.AppendLine(CultureInfo.InvariantCulture,
$"- **Conditions:** {string.Join(", ", tactic.Conditions)}");
}
sb.AppendLine();
}
private static void FormatKnownIssue(StringBuilder sb, KnownIssue issue)
{
var relevance = issue.Relevance.ToString("P0", CultureInfo.InvariantCulture);
sb.AppendLine(CultureInfo.InvariantCulture, $"#### {issue.Title} ({relevance} relevant)");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(issue.Description))
{
sb.AppendLine(issue.Description);
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(issue.RecommendedAction))
{
sb.AppendLine(CultureInfo.InvariantCulture, $"**Recommended:** {issue.RecommendedAction}");
}
sb.AppendLine();
}
}
/// <summary>
/// Result of prompt enrichment with OpsMemory context.
/// </summary>
public sealed record EnrichedPromptResult
{
/// <summary>
/// Gets the enriched prompt with OpsMemory context.
/// </summary>
public required string EnrichedPrompt { get; init; }
/// <summary>
/// Gets additional content for the system prompt.
/// </summary>
public string? SystemPromptAddition { get; init; }
/// <summary>
/// Gets the full OpsMemory context used for enrichment.
/// </summary>
public required OpsMemoryContext Context { get; init; }
/// <summary>
/// Gets the memory IDs of decisions referenced.
/// </summary>
public ImmutableArray<string> DecisionsReferenced { get; init; } = [];
/// <summary>
/// Gets the tactic IDs applied.
/// </summary>
public ImmutableArray<string> TacticsApplied { get; init; } = [];
/// <summary>
/// Gets whether any OpsMemory context was added.
/// </summary>
public bool HasEnrichment => Context.HasPlaybookEntries;
}

View File

@@ -0,0 +1,161 @@
// <copyright file="OpsMemoryDecisionHook.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Services;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using System.Collections.Immutable;
namespace StellaOps.OpsMemory.Integration;
/// <summary>
/// Decision hook that records Findings decisions to OpsMemory for playbook learning.
/// Sprint: SPRINT_20260107_006_004 Task: OM-007
/// </summary>
public sealed class OpsMemoryDecisionHook : IDecisionHook
{
private readonly IOpsMemoryStore _store;
private readonly ISimilarityVectorGenerator _vectorGenerator;
private readonly TimeProvider _timeProvider;
private readonly ILogger<OpsMemoryDecisionHook> _logger;
public OpsMemoryDecisionHook(
IOpsMemoryStore store,
ISimilarityVectorGenerator vectorGenerator,
TimeProvider timeProvider,
ILogger<OpsMemoryDecisionHook> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_vectorGenerator = vectorGenerator ?? throw new ArgumentNullException(nameof(vectorGenerator));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task OnDecisionRecordedAsync(
DecisionEvent decision,
string tenantId,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Recording decision {DecisionId} to OpsMemory for tenant {TenantId}",
decision.Id, tenantId);
try
{
// Extract situation context from the decision
var situation = ExtractSituation(decision);
// Generate similarity vector for future matching
var vector = _vectorGenerator.Generate(situation);
// Map decision to OpsMemory record
var record = new OpsMemoryRecord
{
MemoryId = $"om-{decision.Id}",
TenantId = tenantId,
RecordedAt = _timeProvider.GetUtcNow(),
Situation = situation,
Decision = new DecisionRecord
{
Action = MapDecisionAction(decision.DecisionStatus),
Rationale = BuildRationale(decision),
DecidedBy = decision.ActorId,
DecidedAt = decision.Timestamp,
PolicyReference = decision.PolicyContext,
VexStatementId = null, // Would be extracted from evidence if available
Mitigation = null
},
Outcome = null, // Outcome recorded later via OutcomeTrackingService
SimilarityVector = vector
};
await _store.RecordDecisionAsync(record, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Decision {DecisionId} recorded to OpsMemory as {MemoryId}",
decision.Id, record.MemoryId);
}
catch (Exception ex)
{
// Log but don't throw - this is fire-and-forget
_logger.LogWarning(
ex,
"Failed to record decision {DecisionId} to OpsMemory: {Message}",
decision.Id, ex.Message);
}
}
/// <summary>
/// Extracts situation context from a decision event.
/// </summary>
private static SituationContext ExtractSituation(DecisionEvent decision)
{
// Parse alert ID format: tenant|artifact|vuln or similar
var parts = decision.AlertId.Split('|');
var vulnId = parts.Length > 2 ? parts[2] : null;
// Extract CVE from vulnerability ID if present
string? cveId = null;
if (vulnId?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
{
cveId = vulnId;
}
return new SituationContext
{
CveId = cveId,
Component = null, // Would be extracted from evidence bundle
ComponentName = null,
ComponentVersion = null,
Severity = null, // Would be extracted from finding data
CvssScore = null,
Reachability = ReachabilityStatus.Unknown,
EpssScore = null,
IsKev = false,
ContextTags = ImmutableArray<string>.Empty,
AdditionalContext = ImmutableDictionary<string, string>.Empty
.Add("artifact_id", decision.ArtifactId)
.Add("alert_id", decision.AlertId)
.Add("reason_code", decision.ReasonCode)
};
}
/// <summary>
/// Maps decision status to OpsMemory decision action.
/// </summary>
private static DecisionAction MapDecisionAction(string decisionStatus)
{
return decisionStatus.ToLowerInvariant() switch
{
"affected" => DecisionAction.Remediate,
"not_affected" => DecisionAction.Accept,
"under_investigation" => DecisionAction.Defer,
_ => DecisionAction.Defer
};
}
/// <summary>
/// Builds a rationale string from decision data.
/// </summary>
private static string BuildRationale(DecisionEvent decision)
{
var parts = new List<string>
{
$"Status: {decision.DecisionStatus}",
$"Reason: {decision.ReasonCode}"
};
if (!string.IsNullOrWhiteSpace(decision.ReasonText))
{
parts.Add($"Details: {decision.ReasonText}");
}
return string.Join("; ", parts);
}
}

View File

@@ -0,0 +1,303 @@
// <copyright file="OpsMemoryRecord.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </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,
/// <summary>Decision failed to execute.</summary>
Failure
}

View File

@@ -0,0 +1,36 @@
// <copyright file="IPlaybookSuggestionService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Models;
namespace StellaOps.OpsMemory.Playbook;
/// <summary>
/// Service for generating playbook suggestions based on past decisions.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-002 (extracted interface)
/// </summary>
public interface IPlaybookSuggestionService
{
/// <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>
Task<PlaybookSuggestionResult> GetSuggestionsAsync(
PlaybookSuggestionRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets playbook suggestions for a situation context.
/// </summary>
/// <param name="situation">The situation to analyze.</param>
/// <param name="maxSuggestions">Maximum suggestions to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Playbook suggestions.</returns>
Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
SituationContext situation,
int maxSuggestions,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,419 @@
// <copyright file="PlaybookSuggestionService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using System.Collections.Immutable;
using System.Globalization;
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 : IPlaybookSuggestionService
{
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)
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
SituationContext situation,
int maxSuggestions,
CancellationToken cancellationToken = default)
{
// Create a default request with tenant placeholder
// In real use, the tenant would be extracted from context
var request = new PlaybookSuggestionRequest
{
TenantId = "default",
Situation = situation,
MaxSuggestions = maxSuggestions
};
var result = await GetSuggestionsAsync(request, cancellationToken).ConfigureAwait(false);
return result.Suggestions;
}
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,31 @@
// <copyright file="ISimilarityVectorGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Models;
using System.Collections.Immutable;
namespace StellaOps.OpsMemory.Similarity;
/// <summary>
/// Interface for generating similarity vectors from situation contexts.
/// Sprint: SPRINT_20260107_006_004 Task OM-004
/// </summary>
public interface ISimilarityVectorGenerator
{
/// <summary>
/// Generates a similarity vector from a situation context.
/// </summary>
/// <param name="situation">The situation to vectorize.</param>
/// <returns>A normalized similarity vector.</returns>
ImmutableArray<float> Generate(SituationContext situation);
/// <summary>
/// Gets the factors that contributed to similarity between two situations.
/// </summary>
/// <param name="a">First situation.</param>
/// <param name="b">Second situation.</param>
/// <returns>List of matching factors.</returns>
ImmutableArray<string> GetMatchingFactors(SituationContext a, SituationContext b);
}

View File

@@ -0,0 +1,292 @@
// <copyright file="SimilarityVectorGenerator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Models;
using System.Collections.Immutable;
using System.Globalization;
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 : ISimilarityVectorGenerator
{
// 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,19 @@
<?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>
<ItemGroup>
<ProjectReference Include="..\..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,88 @@
// <copyright file="IKnownIssueStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Integration;
using System.Collections.Immutable;
namespace StellaOps.OpsMemory.Storage;
/// <summary>
/// Storage interface for known issues.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-007
/// </summary>
public interface IKnownIssueStore
{
/// <summary>
/// Creates a new known issue.
/// </summary>
/// <param name="issue">The issue to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created issue with assigned ID.</returns>
Task<KnownIssue> CreateAsync(
KnownIssue issue,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing known issue.
/// </summary>
/// <param name="issue">The issue to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated issue.</returns>
Task<KnownIssue?> UpdateAsync(
KnownIssue issue,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a known issue by ID.
/// </summary>
/// <param name="issueId">The issue ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The issue or null if not found.</returns>
Task<KnownIssue?> GetByIdAsync(
string issueId,
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Finds known issues by context (CVE, component, or tags).
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cveId">Optional CVE ID to match.</param>
/// <param name="component">Optional component PURL to match.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Matching known issues with relevance scores.</returns>
Task<ImmutableArray<KnownIssue>> FindByContextAsync(
string tenantId,
string? cveId,
string? component,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all known issues for a tenant.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="limit">Maximum number of issues to return.</param>
/// <param name="offset">Number of issues to skip.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated list of known issues.</returns>
Task<ImmutableArray<KnownIssue>> ListAsync(
string tenantId,
int limit = 50,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a known issue.
/// </summary>
/// <param name="issueId">The issue ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteAsync(
string issueId,
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,306 @@
// <copyright file="IOpsMemoryStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Models;
using System.Collections.Immutable;
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,140 @@
// <copyright file="ITacticStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.OpsMemory.Integration;
using System.Collections.Immutable;
namespace StellaOps.OpsMemory.Storage;
/// <summary>
/// Storage interface for playbook tactics.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-007
/// </summary>
public interface ITacticStore
{
/// <summary>
/// Creates a new tactic.
/// </summary>
/// <param name="tactic">The tactic to create.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created tactic with assigned ID.</returns>
Task<Tactic> CreateAsync(
Tactic tactic,
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing tactic.
/// </summary>
/// <param name="tactic">The tactic to update.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated tactic.</returns>
Task<Tactic?> UpdateAsync(
Tactic tactic,
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a tactic by ID.
/// </summary>
/// <param name="tacticId">The tactic ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The tactic or null if not found.</returns>
Task<Tactic?> GetByIdAsync(
string tacticId,
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Finds tactics matching the given trigger conditions.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="trigger">The trigger conditions to match.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Matching tactics ordered by confidence.</returns>
Task<ImmutableArray<Tactic>> FindByTriggerAsync(
string tenantId,
TacticTrigger trigger,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all tactics for a tenant.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="limit">Maximum number of tactics to return.</param>
/// <param name="offset">Number of tactics to skip.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated list of tactics.</returns>
Task<ImmutableArray<Tactic>> ListAsync(
string tenantId,
int limit = 50,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Records usage of a tactic (updates usage count and success rate).
/// </summary>
/// <param name="tacticId">The tactic ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="wasSuccessful">Whether the tactic application was successful.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated tactic.</returns>
Task<Tactic?> RecordUsageAsync(
string tacticId,
string tenantId,
bool wasSuccessful,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a tactic.
/// </summary>
/// <param name="tacticId">The tactic ID.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteAsync(
string tacticId,
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Trigger conditions for matching tactics.
/// </summary>
public sealed record TacticTrigger
{
/// <summary>
/// Gets the severities to match.
/// </summary>
public ImmutableArray<string> Severities { get; init; } = [];
/// <summary>
/// Gets the CVE categories to match.
/// </summary>
public ImmutableArray<string> CveCategories { get; init; } = [];
/// <summary>
/// Gets whether to require reachability.
/// </summary>
public bool? RequiresReachable { get; init; }
/// <summary>
/// Gets the minimum EPSS score.
/// </summary>
public double? MinEpssScore { get; init; }
/// <summary>
/// Gets the minimum CVSS score.
/// </summary>
public double? MinCvssScore { get; init; }
/// <summary>
/// Gets context tags to match.
/// </summary>
public ImmutableArray<string> ContextTags { get; init; } = [];
}

View File

@@ -0,0 +1,697 @@
// <copyright file="PostgresOpsMemoryStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.OpsMemory.Models;
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
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,8 @@
# StellaOps.OpsMemory Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory/StellaOps.OpsMemory.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,421 @@
// <copyright file="OutcomeTrackingService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Storage;
using System.Collections.Immutable;
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

@@ -24,7 +24,7 @@
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\Plugin\StellaOps.Plugin.Testing\StellaOps.Plugin.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,349 @@
// <copyright file="OpsMemoryChatProviderIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.OpsMemory.Integration;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using Xunit;
namespace StellaOps.OpsMemory.Tests.Integration;
/// <summary>
/// Integration tests for OpsMemoryChatProvider.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-009
/// Uses Testcontainers to provision an ephemeral PostgreSQL instance.
/// </summary>
[Trait("Category", "Integration")]
[Collection(OpsMemoryPostgresCollection.Name)]
public sealed class OpsMemoryChatProviderIntegrationTests : IAsyncLifetime
{
private readonly OpsMemoryPostgresFixture _fixture;
private NpgsqlDataSource? _dataSource;
private PostgresOpsMemoryStore? _store;
private OpsMemoryChatProvider? _chatProvider;
private SimilarityVectorGenerator? _vectorGenerator;
private string _testTenantId = string.Empty;
public OpsMemoryChatProviderIntegrationTests(OpsMemoryPostgresFixture fixture)
{
_fixture = fixture;
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
_dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
_store = new PostgresOpsMemoryStore(
_dataSource,
NullLogger<PostgresOpsMemoryStore>.Instance);
_vectorGenerator = new SimilarityVectorGenerator();
// Create chat provider with required dependencies
_chatProvider = new OpsMemoryChatProvider(
_store,
_vectorGenerator,
new NullPlaybookSuggestionService(),
TimeProvider.System,
NullLogger<OpsMemoryChatProvider>.Instance);
_testTenantId = $"test-{Guid.NewGuid()}";
}
public async ValueTask DisposeAsync()
{
if (_store != null)
{
await _store.DisposeAsync();
}
if (_dataSource != null)
{
await _dataSource.DisposeAsync();
}
}
[Fact]
public async Task EnrichContext_WithNoHistory_ReturnsEmptyContext()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var request = new ChatContextRequest
{
TenantId = _testTenantId,
CveId = "CVE-2024-9999",
Severity = "HIGH",
Reachability = ReachabilityStatus.Reachable
};
// Act
var context = await _chatProvider!.EnrichContextAsync(request, ct);
// Assert
context.SimilarDecisions.Should().BeEmpty();
context.HasPlaybookEntries.Should().BeFalse();
}
[Fact]
public async Task EnrichContext_WithSimilarDecisions_ReturnsSortedBySimilarity()
{
var ct = TestContext.Current.CancellationToken;
var now = DateTimeOffset.UtcNow;
// Arrange - Create decisions with different similarity levels
var record1 = await CreateAndStoreDecision(
_testTenantId, "CVE-2024-1234", "pkg:npm/test@1.0.0", "HIGH",
DecisionAction.Remediate, OutcomeStatus.Success, now);
var record2 = await CreateAndStoreDecision(
_testTenantId, "CVE-2024-5678", "pkg:npm/test@1.0.0", "CRITICAL",
DecisionAction.Quarantine, OutcomeStatus.Success, now);
var record3 = await CreateAndStoreDecision(
_testTenantId, "CVE-2024-9012", "pkg:maven/other@2.0.0", "LOW",
DecisionAction.Accept, OutcomeStatus.Success, now);
var request = new ChatContextRequest
{
TenantId = _testTenantId,
CveId = "CVE-2024-NEW1",
Component = "pkg:npm/test@1.0.0",
Severity = "HIGH",
Reachability = ReachabilityStatus.Reachable,
MaxSuggestions = 3,
MinSimilarity = 0.3
};
// Act
var context = await _chatProvider!.EnrichContextAsync(request, ct);
// Assert
context.SimilarDecisions.Should().NotBeEmpty();
context.HasPlaybookEntries.Should().BeTrue();
// Similar npm/HIGH decisions should rank higher than maven/LOW
if (context.SimilarDecisions.Length >= 2)
{
context.SimilarDecisions[0].SimilarityScore.Should()
.BeGreaterThanOrEqualTo(context.SimilarDecisions[1].SimilarityScore);
}
}
[Fact]
public async Task EnrichContext_FiltersOutFailedDecisions()
{
var ct = TestContext.Current.CancellationToken;
var now = DateTimeOffset.UtcNow;
// Arrange - Create one successful and one failed decision
await CreateAndStoreDecision(
_testTenantId, "CVE-SUCCESS-001", "pkg:npm/test@1.0.0", "HIGH",
DecisionAction.Defer, OutcomeStatus.Success, now);
await CreateAndStoreDecision(
_testTenantId, "CVE-FAILURE-001", "pkg:npm/test@1.0.0", "HIGH",
DecisionAction.Accept, OutcomeStatus.Failure, now);
var request = new ChatContextRequest
{
TenantId = _testTenantId,
Component = "pkg:npm/test@1.0.0",
Severity = "HIGH",
MaxSuggestions = 10,
MinSimilarity = 0.0
};
// Act
var context = await _chatProvider!.EnrichContextAsync(request, ct);
// Assert - Only successful decisions should be returned
context.SimilarDecisions.Should().AllSatisfy(d =>
d.OutcomeStatus.Should().BeOneOf(OutcomeStatus.Success, OutcomeStatus.PartialSuccess));
}
[Fact]
public async Task RecordFromAction_CreatesOpsMemoryRecord()
{
var ct = TestContext.Current.CancellationToken;
var now = DateTimeOffset.UtcNow;
// Arrange
var action = new ActionExecutionResult
{
Action = DecisionAction.Remediate,
CveId = "CVE-2024-ACTION-001",
Component = "pkg:npm/vulnerable@1.0.0",
Success = true,
Rationale = "Upgrading to patched version",
ExecutedAt = now,
ActorId = "user:alice@example.com"
};
var context = new ConversationContext
{
ConversationId = "conv-123",
TenantId = _testTenantId,
UserId = "alice@example.com",
Topic = "CVE Remediation",
Situation = new SituationContext
{
CveId = "CVE-2024-ACTION-001",
Component = "pkg:npm/vulnerable@1.0.0",
Severity = "HIGH",
Reachability = ReachabilityStatus.Reachable
}
};
// Act
var record = await _chatProvider!.RecordFromActionAsync(action, context, ct);
// Assert
record.Should().NotBeNull();
record.TenantId.Should().Be(_testTenantId);
record.Situation.CveId.Should().Be("CVE-2024-ACTION-001");
record.Decision.Action.Should().Be(DecisionAction.Remediate);
record.Decision.Rationale.Should().Be("Upgrading to patched version");
// Verify persisted
var retrieved = await _store!.GetByIdAsync(record.MemoryId, _testTenantId, ct);
retrieved.Should().NotBeNull();
}
[Fact]
public async Task GetRecentDecisions_ReturnsOrderedByDate()
{
var ct = TestContext.Current.CancellationToken;
var now = DateTimeOffset.UtcNow;
// Arrange - Create decisions at different times
await CreateAndStoreDecision(_testTenantId, "CVE-OLDEST", "pkg:test@1", "LOW",
DecisionAction.Accept, null, now.AddDays(-10));
await CreateAndStoreDecision(_testTenantId, "CVE-MIDDLE", "pkg:test@1", "MEDIUM",
DecisionAction.Defer, null, now.AddDays(-5));
await CreateAndStoreDecision(_testTenantId, "CVE-NEWEST", "pkg:test@1", "HIGH",
DecisionAction.Remediate, null, now);
// Act
var recent = await _chatProvider!.GetRecentDecisionsAsync(_testTenantId, 3, ct);
// Assert
recent.Should().HaveCount(3);
recent[0].CveId.Should().Be("CVE-NEWEST");
recent[1].CveId.Should().Be("CVE-MIDDLE");
recent[2].CveId.Should().Be("CVE-OLDEST");
}
[Fact]
public async Task EnrichContext_IsTenantIsolated()
{
var ct = TestContext.Current.CancellationToken;
var now = DateTimeOffset.UtcNow;
// Arrange - Create decisions in different tenants
var otherTenantId = $"test-other-{Guid.NewGuid()}";
await CreateAndStoreDecision(_testTenantId, "CVE-TENANT1", "pkg:test@1", "HIGH",
DecisionAction.Remediate, OutcomeStatus.Success, now);
await CreateAndStoreDecision(otherTenantId, "CVE-TENANT2", "pkg:test@1", "HIGH",
DecisionAction.Accept, OutcomeStatus.Success, now);
var request = new ChatContextRequest
{
TenantId = _testTenantId,
Severity = "HIGH",
MaxSuggestions = 10,
MinSimilarity = 0.0
};
// Act
var context = await _chatProvider!.EnrichContextAsync(request, ct);
// Assert - Only decisions from _testTenantId should be returned
context.SimilarDecisions.Should().AllSatisfy(d =>
d.CveId.Should().Be("CVE-TENANT1"));
}
private async Task<OpsMemoryRecord> CreateAndStoreDecision(
string tenantId,
string cveId,
string component,
string severity,
DecisionAction action,
OutcomeStatus? outcome,
DateTimeOffset at)
{
var ct = TestContext.Current.CancellationToken;
var situation = new SituationContext
{
CveId = cveId,
Component = component,
Severity = severity,
Reachability = ReachabilityStatus.Reachable
};
var record = new OpsMemoryRecord
{
MemoryId = Guid.NewGuid().ToString(),
TenantId = tenantId,
RecordedAt = at,
Situation = situation,
Decision = new DecisionRecord
{
Action = action,
Rationale = $"Test decision for {cveId}",
DecidedBy = "test",
DecidedAt = at
},
SimilarityVector = _vectorGenerator!.Generate(situation)
};
await _store!.RecordDecisionAsync(record, ct);
if (outcome.HasValue)
{
await _store.RecordOutcomeAsync(record.MemoryId, tenantId, new OutcomeRecord
{
Status = outcome.Value,
RecordedBy = "test",
RecordedAt = at.AddDays(1)
}, ct);
}
return record;
}
/// <summary>
/// Null implementation of IPlaybookSuggestionService for testing.
/// </summary>
private sealed class NullPlaybookSuggestionService : IPlaybookSuggestionService
{
public Task<PlaybookSuggestionResult> GetSuggestionsAsync(
PlaybookSuggestionRequest request,
CancellationToken cancellationToken = default) =>
Task.FromResult(new PlaybookSuggestionResult
{
Suggestions = ImmutableArray<PlaybookSuggestion>.Empty
});
public Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
SituationContext situation,
int maxSuggestions,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<PlaybookSuggestion>>(
ImmutableArray<PlaybookSuggestion>.Empty);
}
}

View File

@@ -0,0 +1,104 @@
// <copyright file="OpsMemoryPostgresFixture.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.OpsMemory.Tests.Integration;
/// <summary>
/// PostgreSQL integration test fixture for OpsMemory tests.
/// Starts a Testcontainers PostgreSQL instance and applies the opsmemory migration.
/// </summary>
public sealed class OpsMemoryPostgresFixture : IAsyncLifetime
{
private const string PostgresImage = "postgres:16-alpine";
private PostgreSqlContainer? _container;
public string ConnectionString => _container?.GetConnectionString()
?? throw new InvalidOperationException("Container not initialized");
public async ValueTask InitializeAsync()
{
try
{
_container = new PostgreSqlBuilder()
.WithImage(PostgresImage)
.Build();
await _container.StartAsync();
}
catch (ArgumentException ex) when (
string.Equals(ex.ParamName, "DockerEndpointAuthConfig", StringComparison.Ordinal) ||
ex.Message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase))
{
if (_container is not null)
{
try { await _container.DisposeAsync(); } catch { /* ignore */ }
}
_container = null;
throw SkipException.ForSkip(
$"OpsMemory integration tests require Docker/Testcontainers. Skipping: {ex.Message}");
}
// Apply the opsmemory migration
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
var migrationSql = await LoadMigrationSqlAsync();
await using var migrationCmd = new NpgsqlCommand(migrationSql, conn);
migrationCmd.CommandTimeout = 60;
await migrationCmd.ExecuteNonQueryAsync();
}
public async ValueTask DisposeAsync()
{
if (_container is not null)
{
await _container.DisposeAsync();
}
}
/// <summary>
/// Truncates all opsmemory tables for test isolation.
/// </summary>
public async Task TruncateAllTablesAsync()
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"TRUNCATE TABLE opsmemory.decisions CASCADE;", conn);
await cmd.ExecuteNonQueryAsync();
}
private static async Task<string> LoadMigrationSqlAsync()
{
var directory = AppContext.BaseDirectory;
while (directory is not null)
{
var migrationPath = Path.Combine(directory, "devops", "database",
"migrations", "V20260108__opsmemory_advisoryai_schema.sql");
if (File.Exists(migrationPath))
{
return await File.ReadAllTextAsync(migrationPath);
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException(
"Cannot find opsmemory migration SQL. Ensure the test runs from within the repository.");
}
}
[CollectionDefinition(Name)]
public sealed class OpsMemoryPostgresCollection : ICollectionFixture<OpsMemoryPostgresFixture>
{
public const string Name = "OpsMemoryPostgres";
}

View File

@@ -0,0 +1,277 @@
// <copyright file="PostgresOpsMemoryStoreTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </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 Testcontainers to provision an ephemeral PostgreSQL instance.
/// </summary>
[Trait("Category", "Integration")]
[Collection(OpsMemoryPostgresCollection.Name)]
public sealed class PostgresOpsMemoryStoreTests : IAsyncLifetime
{
private readonly OpsMemoryPostgresFixture _fixture;
private NpgsqlDataSource? _dataSource;
private PostgresOpsMemoryStore? _store;
public PostgresOpsMemoryStoreTests(OpsMemoryPostgresFixture fixture)
{
_fixture = fixture;
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
_dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
_store = new PostgresOpsMemoryStore(
_dataSource,
NullLogger<PostgresOpsMemoryStore>.Instance);
}
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,24 @@
<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" />
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
# StellaOps.OpsMemory.Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| S312-OPSMEMORY-VERIFY | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` verification for TASK-312-007: `dotnet test src/AdvisoryAI/__Tests/StellaOps.OpsMemory.Tests/StellaOps.OpsMemory.Tests.csproj -v minimal` passed (50 tests). |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/__Tests/StellaOps.OpsMemory.Tests/StellaOps.OpsMemory.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,316 @@
// <copyright file="OpsMemoryChatProviderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.OpsMemory.Integration;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using Xunit;
namespace StellaOps.OpsMemory.Tests.Unit;
/// <summary>
/// Unit tests for OpsMemoryChatProvider.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpsMemoryChatProviderTests
{
private readonly Mock<IOpsMemoryStore> _storeMock;
private readonly Mock<ISimilarityVectorGenerator> _vectorGeneratorMock;
private readonly Mock<IPlaybookSuggestionService> _playbookMock;
private readonly FakeTimeProvider _timeProvider;
private readonly OpsMemoryChatProvider _sut;
public OpsMemoryChatProviderTests()
{
_storeMock = new Mock<IOpsMemoryStore>();
_vectorGeneratorMock = new Mock<ISimilarityVectorGenerator>();
_playbookMock = new Mock<IPlaybookSuggestionService>();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
_sut = new OpsMemoryChatProvider(
_storeMock.Object,
_vectorGeneratorMock.Object,
_playbookMock.Object,
_timeProvider,
NullLogger<OpsMemoryChatProvider>.Instance);
}
[Fact]
public async Task EnrichContextAsync_WithSimilarDecisions_ReturnsSummaries()
{
// Arrange
var request = new ChatContextRequest
{
TenantId = "tenant-1",
CveId = "CVE-2021-44228",
Severity = "Critical",
MaxSuggestions = 3
};
var pastRecord = CreateTestRecord("om-001", "CVE-2021-44227", OutcomeStatus.Success);
var similarMatches = new List<SimilarityMatch>
{
new SimilarityMatch { Record = pastRecord, SimilarityScore = 0.85 }
};
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(similarMatches);
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PlaybookSuggestion>());
// Act
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
// Assert
Assert.Single(result.SimilarDecisions);
Assert.Equal("om-001", result.SimilarDecisions[0].MemoryId);
Assert.Equal(0.85, result.SimilarDecisions[0].SimilarityScore);
Assert.Equal(OutcomeStatus.Success, result.SimilarDecisions[0].OutcomeStatus);
}
[Fact]
public async Task EnrichContextAsync_WithNoMatches_ReturnsEmptyContext()
{
// Arrange
var request = new ChatContextRequest
{
TenantId = "tenant-1",
CveId = "CVE-2099-99999",
MaxSuggestions = 3
};
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<SimilarityMatch>());
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PlaybookSuggestion>());
// Act
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
// Assert
Assert.Empty(result.SimilarDecisions);
Assert.Empty(result.ApplicableTactics);
Assert.False(result.HasPlaybookEntries);
}
[Fact]
public async Task EnrichContextAsync_OrdersBySimilarityThenOutcome()
{
// Arrange
var request = new ChatContextRequest
{
TenantId = "tenant-1",
MaxSuggestions = 3
};
// Note: Failed outcomes are filtered out, so only successful decisions are returned
var similarMatches = new List<SimilarityMatch>
{
new SimilarityMatch
{
Record = CreateTestRecord("om-001", "CVE-1", OutcomeStatus.PartialSuccess),
SimilarityScore = 0.9
},
new SimilarityMatch
{
Record = CreateTestRecord("om-002", "CVE-2", OutcomeStatus.Success),
SimilarityScore = 0.9
},
new SimilarityMatch
{
Record = CreateTestRecord("om-003", "CVE-3", OutcomeStatus.Success),
SimilarityScore = 0.8
}
};
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(similarMatches);
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PlaybookSuggestion>());
// Act
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
// Assert
Assert.Equal(3, result.SimilarDecisions.Length);
// Highest similarity with success outcome should be first
Assert.Equal("om-002", result.SimilarDecisions[0].MemoryId);
Assert.Equal("om-001", result.SimilarDecisions[1].MemoryId);
Assert.Equal("om-003", result.SimilarDecisions[2].MemoryId);
}
[Fact]
public async Task RecordFromActionAsync_CreatesValidRecord()
{
// Arrange
var action = new ActionExecutionResult
{
Action = DecisionAction.Accept,
CveId = "CVE-2021-44228",
Component = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Success = true,
Rationale = "Risk accepted due to air-gapped environment",
ExecutedAt = _timeProvider.GetUtcNow(),
ActorId = "user-123"
};
var conversationContext = new ConversationContext
{
ConversationId = "conv-001",
TenantId = "tenant-1",
UserId = "user-123",
TurnNumber = 5
};
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
OpsMemoryRecord? capturedRecord = null;
_storeMock.Setup(s => s.RecordDecisionAsync(It.IsAny<OpsMemoryRecord>(), It.IsAny<CancellationToken>()))
.Callback<OpsMemoryRecord, CancellationToken>((r, _) => capturedRecord = r)
.ReturnsAsync((OpsMemoryRecord r, CancellationToken _) => r);
// Act
var result = await _sut.RecordFromActionAsync(action, conversationContext, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.StartsWith("om-chat-", result.MemoryId);
Assert.Equal("tenant-1", result.TenantId);
Assert.Equal(DecisionAction.Accept, result.Decision.Action);
Assert.Equal("user-123", result.Decision.DecidedBy);
Assert.Contains("Risk accepted", result.Decision.Rationale);
}
[Fact]
public async Task GetRecentDecisionsAsync_ReturnsFormattedSummaries()
{
// Arrange
var records = new PagedResult<OpsMemoryRecord>
{
Items = ImmutableArray.Create(
CreateTestRecord("om-001", "CVE-2021-44228", OutcomeStatus.Success),
CreateTestRecord("om-002", "CVE-2021-44229", OutcomeStatus.Failure)
),
TotalCount = 2,
NextCursor = null
};
_storeMock.Setup(s => s.QueryAsync(It.IsAny<OpsMemoryQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(records);
// Act
var result = await _sut.GetRecentDecisionsAsync("tenant-1", 10, CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("om-001", result[0].MemoryId);
Assert.Equal("om-002", result[1].MemoryId);
}
[Fact]
public async Task EnrichContextAsync_GeneratesPromptSegment()
{
// Arrange
var request = new ChatContextRequest
{
TenantId = "tenant-1",
CveId = "CVE-2021-44228",
MaxSuggestions = 3
};
var pastRecord = CreateTestRecord("om-001", "CVE-2021-44227", OutcomeStatus.Success);
var similarMatches = new List<SimilarityMatch>
{
new SimilarityMatch { Record = pastRecord, SimilarityScore = 0.85 }
};
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(similarMatches);
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PlaybookSuggestion>());
// Act
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
// Assert
Assert.NotNull(result.PromptSegment);
Assert.Contains("Previous Similar Situations", result.PromptSegment);
Assert.Contains("CVE-2021-44227", result.PromptSegment);
Assert.Contains("[SUCCESS]", result.PromptSegment);
}
private static OpsMemoryRecord CreateTestRecord(string memoryId, string cveId, OutcomeStatus? outcomeStatus)
{
return new OpsMemoryRecord
{
MemoryId = memoryId,
TenantId = "tenant-1",
RecordedAt = DateTimeOffset.UtcNow,
Situation = new SituationContext
{
CveId = cveId,
Severity = "High",
Reachability = ReachabilityStatus.Unknown
},
Decision = new DecisionRecord
{
Action = DecisionAction.Accept,
Rationale = "Test rationale",
DecidedBy = "test-user",
DecidedAt = DateTimeOffset.UtcNow
},
Outcome = outcomeStatus.HasValue
? new OutcomeRecord
{
Status = outcomeStatus.Value,
RecordedBy = "test-user",
RecordedAt = DateTimeOffset.UtcNow
}
: null
};
}
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
public sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration)
{
_now = _now.Add(duration);
}
}

View File

@@ -0,0 +1,268 @@
// <copyright file="OpsMemoryContextEnricherTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.OpsMemory.Integration;
using StellaOps.OpsMemory.Models;
using Xunit;
namespace StellaOps.OpsMemory.Tests.Unit;
/// <summary>
/// Unit tests for OpsMemoryContextEnricher.
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpsMemoryContextEnricherTests
{
private readonly Mock<IOpsMemoryChatProvider> _chatProviderMock;
private readonly OpsMemoryContextEnricher _sut;
public OpsMemoryContextEnricherTests()
{
_chatProviderMock = new Mock<IOpsMemoryChatProvider>();
_sut = new OpsMemoryContextEnricher(
_chatProviderMock.Object,
NullLogger<OpsMemoryContextEnricher>.Instance);
}
[Fact]
public async Task EnrichPromptAsync_WithDecisions_IncludesContextBlock()
{
// Arrange
var request = new ChatContextRequest
{
TenantId = "tenant-1",
CveId = "CVE-2021-44228"
};
var context = new OpsMemoryContext
{
SimilarDecisions = ImmutableArray.Create(
new PastDecisionSummary
{
MemoryId = "om-001",
CveId = "CVE-2021-44227",
Action = DecisionAction.Accept,
OutcomeStatus = OutcomeStatus.Success,
SimilarityScore = 0.85,
DecidedAt = DateTimeOffset.UtcNow,
Rationale = "Air-gapped environment"
}
)
};
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(context);
// Act
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
// Assert
Assert.True(result.HasEnrichment);
Assert.Contains("Institutional Memory", result.EnrichedPrompt);
Assert.Contains("CVE-2021-44227", result.EnrichedPrompt);
Assert.Contains("Accept", result.EnrichedPrompt);
Assert.Contains("[SUCCESS]", result.EnrichedPrompt);
}
[Fact]
public async Task EnrichPromptAsync_WithExistingPrompt_AppendsContextBlock()
{
// Arrange
var request = new ChatContextRequest { TenantId = "tenant-1" };
var existingPrompt = "User asks about vulnerability remediation.";
var context = new OpsMemoryContext
{
SimilarDecisions = ImmutableArray.Create(
new PastDecisionSummary
{
MemoryId = "om-001",
CveId = "CVE-2021-44228",
Action = DecisionAction.Remediate,
OutcomeStatus = OutcomeStatus.Success,
SimilarityScore = 0.9,
DecidedAt = DateTimeOffset.UtcNow
}
)
};
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(context);
// Act
var result = await _sut.EnrichPromptAsync(request, existingPrompt, CancellationToken.None);
// Assert
Assert.StartsWith("User asks about vulnerability remediation.", result.EnrichedPrompt);
Assert.Contains("Institutional Memory", result.EnrichedPrompt);
}
[Fact]
public async Task EnrichPromptAsync_WithNoEntries_ReturnsEmptyEnrichment()
{
// Arrange
var request = new ChatContextRequest { TenantId = "tenant-1" };
var emptyContext = new OpsMemoryContext
{
SimilarDecisions = ImmutableArray<PastDecisionSummary>.Empty,
ApplicableTactics = ImmutableArray<Tactic>.Empty,
RelevantKnownIssues = ImmutableArray<KnownIssue>.Empty
};
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(emptyContext);
// Act
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
// Assert
Assert.False(result.HasEnrichment);
Assert.Empty(result.EnrichedPrompt);
}
[Fact]
public async Task EnrichPromptAsync_WithTactics_IncludesPlaybookSection()
{
// Arrange
var request = new ChatContextRequest { TenantId = "tenant-1" };
var context = new OpsMemoryContext
{
ApplicableTactics = ImmutableArray.Create(
new Tactic
{
TacticId = "tac-001",
Name = "Immediate Patch",
Description = "Apply vendor patch immediately for critical vulnerabilities",
RecommendedAction = DecisionAction.Remediate,
Confidence = 0.9,
SuccessRate = 0.95
}
)
};
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(context);
// Act
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
// Assert
Assert.True(result.HasEnrichment);
Assert.Contains("Playbook Tactics", result.EnrichedPrompt);
Assert.Contains("Immediate Patch", result.EnrichedPrompt);
Assert.Contains("95", result.EnrichedPrompt); // Success rate formatted as percentage
}
[Fact]
public void BuildSystemPromptAddition_WithPlaybookEntries_IncludesInstructions()
{
// Arrange
var context = new OpsMemoryContext
{
SimilarDecisions = ImmutableArray.Create(
new PastDecisionSummary
{
MemoryId = "om-001",
CveId = "CVE-2021-44228",
Action = DecisionAction.Accept,
SimilarityScore = 0.85,
DecidedAt = DateTimeOffset.UtcNow
}
),
ApplicableTactics = ImmutableArray.Create(
new Tactic
{
TacticId = "tac-001",
Name = "Test Tactic",
Description = "Test description",
Confidence = 0.8
}
)
};
// Act
var result = OpsMemoryContextEnricher.BuildSystemPromptAddition(context);
// Assert
Assert.Contains("OpsMemory Instructions", result);
Assert.Contains("[ops-mem:ID]", result);
Assert.Contains("1 similar situations", result);
Assert.Contains("1 tactics available", result);
}
[Fact]
public void BuildContextBlock_WithLessonsLearned_IncludesLessons()
{
// Arrange
var context = new OpsMemoryContext
{
SimilarDecisions = ImmutableArray.Create(
new PastDecisionSummary
{
MemoryId = "om-001",
CveId = "CVE-2021-44228",
Action = DecisionAction.Accept,
OutcomeStatus = OutcomeStatus.Failure,
SimilarityScore = 0.85,
DecidedAt = DateTimeOffset.UtcNow,
LessonsLearned = "Should have patched despite low priority"
}
)
};
// Act
var result = OpsMemoryContextEnricher.BuildContextBlock(context);
// Assert
Assert.Contains("[FAILED]", result);
Assert.Contains("Should have patched", result);
Assert.Contains("Lessons:", result);
}
[Fact]
public async Task EnrichPromptAsync_TracksReferencedMemoryIds()
{
// Arrange
var request = new ChatContextRequest { TenantId = "tenant-1" };
var context = new OpsMemoryContext
{
SimilarDecisions = ImmutableArray.Create(
new PastDecisionSummary
{
MemoryId = "om-001",
CveId = "CVE-1",
Action = DecisionAction.Accept,
SimilarityScore = 0.9,
DecidedAt = DateTimeOffset.UtcNow
},
new PastDecisionSummary
{
MemoryId = "om-002",
CveId = "CVE-2",
Action = DecisionAction.Remediate,
SimilarityScore = 0.8,
DecidedAt = DateTimeOffset.UtcNow
}
)
};
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(context);
// Act
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
// Assert
Assert.Equal(2, result.DecisionsReferenced.Length);
Assert.Contains("om-001", result.DecisionsReferenced);
Assert.Contains("om-002", result.DecisionsReferenced);
}
}

View File

@@ -0,0 +1,284 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.OpsMemory.Models;
using StellaOps.OpsMemory.Playbook;
using StellaOps.OpsMemory.Similarity;
using StellaOps.OpsMemory.Storage;
using Xunit;
namespace StellaOps.OpsMemory.Tests.Unit;
/// <summary>
/// Unit tests for PlaybookSuggestionService.
/// Sprint: SPRINT_20260107_006_004 Task OM-010
/// </summary>
[Trait("Category", "Unit")]
public class PlaybookSuggestionServiceTests
{
private readonly Mock<IOpsMemoryStore> _storeMock;
private readonly SimilarityVectorGenerator _vectorGenerator;
private readonly PlaybookSuggestionService _service;
public PlaybookSuggestionServiceTests()
{
_storeMock = new Mock<IOpsMemoryStore>();
_vectorGenerator = new SimilarityVectorGenerator();
_service = new PlaybookSuggestionService(
_storeMock.Object,
_vectorGenerator,
NullLogger<PlaybookSuggestionService>.Instance);
}
[Fact]
public async Task GetSuggestionsAsync_WithNoSimilarRecords_ReturnsEmptySuggestions()
{
// Arrange
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<SimilarityMatch>());
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext
{
CveId = "CVE-2023-12345",
Severity = "high"
}
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().BeEmpty();
result.AnalyzedRecords.Should().Be(0);
result.HasSuggestions.Should().BeFalse();
}
[Fact]
public async Task GetSuggestionsAsync_WithSimilarRecords_ReturnsSuggestions()
{
// Arrange
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var matches = new List<SimilarityMatch>
{
new()
{
Record = pastRecord,
SimilarityScore = 0.85
}
};
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(matches);
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext
{
CveId = "CVE-2023-12345",
Severity = "high",
Reachability = ReachabilityStatus.Reachable
}
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().NotBeEmpty();
result.AnalyzedRecords.Should().Be(1);
result.HasSuggestions.Should().BeTrue();
}
[Fact]
public async Task GetSuggestionsAsync_GroupsByAction_AndRanksBySuccessRate()
{
// Arrange - multiple records with same action
var remediateSuccess1 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var remediateSuccess2 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var acceptPartial = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.PartialSuccess);
var matches = new List<SimilarityMatch>
{
new() { Record = remediateSuccess1, SimilarityScore = 0.9 },
new() { Record = remediateSuccess2, SimilarityScore = 0.85 },
new() { Record = acceptPartial, SimilarityScore = 0.8 }
};
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(matches);
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext { Severity = "high" }
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().NotBeEmpty();
// Remediate should rank higher due to 100% success rate
var firstSuggestion = result.Suggestions.First();
firstSuggestion.Action.Should().Be(DecisionAction.Remediate);
firstSuggestion.SuccessRate.Should().Be(1.0); // 100%
}
[Fact]
public async Task GetSuggestionsAsync_RespectsMaxSuggestionsLimit()
{
// Arrange - more actions than max suggestions
var remediate = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var accept = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.Success);
var mitigate = CreatePastRecord(DecisionAction.Mitigate, OutcomeStatus.Success);
var defer = CreatePastRecord(DecisionAction.Defer, OutcomeStatus.Success);
var matches = new List<SimilarityMatch>
{
new() { Record = remediate, SimilarityScore = 0.9 },
new() { Record = accept, SimilarityScore = 0.85 },
new() { Record = mitigate, SimilarityScore = 0.8 },
new() { Record = defer, SimilarityScore = 0.75 }
};
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(matches);
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext { Severity = "high" },
MaxSuggestions = 2
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().HaveCount(2);
}
[Fact]
public async Task GetSuggestionsAsync_IncludesEvidenceRecords()
{
// Arrange
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var matches = new List<SimilarityMatch>
{
new() { Record = pastRecord, SimilarityScore = 0.9 }
};
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(matches);
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext { Severity = "high" }
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().NotBeEmpty();
var suggestion = result.Suggestions.First();
suggestion.Evidence.Should().NotBeEmpty();
suggestion.Evidence.First().MemoryId.Should().Be(pastRecord.MemoryId);
}
[Fact]
public async Task GetSuggestionsAsync_CalculatesConfidence()
{
// Arrange
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var matches = new List<SimilarityMatch>
{
new() { Record = pastRecord, SimilarityScore = 0.9 }
};
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(matches);
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext { Severity = "high" }
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().NotBeEmpty();
var suggestion = result.Suggestions.First();
suggestion.Confidence.Should().BeGreaterThan(0);
suggestion.Confidence.Should().BeLessThanOrEqualTo(1);
}
[Fact]
public async Task GetSuggestionsAsync_GeneratesRationale()
{
// Arrange
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
var matches = new List<SimilarityMatch>
{
new() { Record = pastRecord, SimilarityScore = 0.9 }
};
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(matches);
var request = new PlaybookSuggestionRequest
{
TenantId = "tenant-1",
Situation = new SituationContext { Severity = "high" }
};
// Act
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
// Assert
result.Suggestions.Should().NotBeEmpty();
var suggestion = result.Suggestions.First();
suggestion.Rationale.Should().NotBeNullOrEmpty();
}
private static OpsMemoryRecord CreatePastRecord(DecisionAction action, OutcomeStatus outcome)
{
var memoryId = Guid.NewGuid().ToString("N");
return new OpsMemoryRecord
{
MemoryId = memoryId,
TenantId = "tenant-1",
RecordedAt = DateTimeOffset.UtcNow.AddDays(-7),
Situation = new SituationContext
{
CveId = "CVE-2023-44487",
Severity = "high",
Reachability = ReachabilityStatus.Reachable
},
Decision = new DecisionRecord
{
Action = action,
Rationale = "Test decision rationale",
DecidedBy = "test-user",
DecidedAt = DateTimeOffset.UtcNow.AddDays(-7)
},
Outcome = new OutcomeRecord
{
Status = outcome,
ResolutionTime = TimeSpan.FromHours(4),
RecordedBy = "test-user",
RecordedAt = DateTimeOffset.UtcNow.AddDays(-5)
}
};
}
}

View File

@@ -0,0 +1,345 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
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));
}
}