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

@@ -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;
}