Add signal contracts for reachability, exploitability, trust, and unknown symbols
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / sign-signals-artifacts (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / verify-signatures (push) Has been cancelled

- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties.
- Implemented JSON serialization attributes for proper data interchange.
- Created project files for the new signal contracts library and corresponding test projects.
- Added deterministic test fixtures for micro-interaction testing.
- Included cryptographic keys for secure operations with cosign.
This commit is contained in:
StellaOps Bot
2025-12-05 00:27:00 +02:00
parent b018949a8d
commit 8768c27f30
192 changed files with 27569 additions and 2552 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,6 +9,7 @@ internal static class ConsoleSimulationEndpoint
public static IEndpointRouteBuilder MapConsoleSimulationDiff(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/console/simulations/diff", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.ConsoleSimulationDiff")
.Produces<ConsoleSimulationDiffResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Overlay;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,6 +9,7 @@ public static class OverlaySimulationEndpoint
public static IEndpointRouteBuilder MapOverlaySimulation(this IEndpointRouteBuilder routes)
{
routes.MapPost("/simulation/overlay", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.OverlaySimulation");
return routes;

View File

@@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Overlay;
@@ -12,6 +13,7 @@ public static class PathScopeSimulationEndpoint
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
{
routes.MapPost("/simulation/path-scope", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.PathScopeSimulation");
return routes;

View File

@@ -82,6 +82,12 @@ internal static class RiskProfileEndpoints
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/metadata", GetProfileMetadata)
.WithName("GetRiskProfileMetadata")
.WithSummary("Export risk profile metadata for notification enrichment (POLICY-RISK-40-002).")
.Produces<RiskProfileMetadataExportResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
@@ -461,6 +467,53 @@ internal static class RiskProfileEndpoints
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
}
private static IResult GetProfileMetadata(
HttpContext context,
[FromRoute] string profileId,
RiskProfileConfigurationService profileService,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var profile = profileService.GetProfile(profileId);
if (profile == null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = $"Risk profile '{profileId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
var versions = lifecycleService.GetAllVersions(profileId);
var activeVersion = versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
var hash = profileService.ComputeHash(profile);
// Extract signal names and severity thresholds for notification context
var signalNames = profile.Signals.Select(s => s.Name).ToList();
var severityThresholds = profile.Overrides.Severity
.Select(s => new SeverityThresholdInfo(s.Set.ToString(), s.When))
.ToList();
return Results.Ok(new RiskProfileMetadataExportResponse(
ProfileId: profile.Id,
Version: profile.Version,
Description: profile.Description,
Hash: hash,
Status: activeVersion?.Status.ToString() ?? "unknown",
SignalNames: signalNames,
SeverityThresholds: severityThresholds,
CustomMetadata: profile.Metadata,
ExtendsProfile: profile.Extends,
ExportedAt: DateTime.UtcNow
));
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
@@ -521,4 +574,26 @@ internal sealed record CompareRiskProfilesRequest(
string ToProfileId,
string ToVersion);
/// <summary>
/// Metadata export response for notification enrichment (POLICY-RISK-40-002).
/// </summary>
internal sealed record RiskProfileMetadataExportResponse(
string ProfileId,
string Version,
string? Description,
string Hash,
string Status,
IReadOnlyList<string> SignalNames,
IReadOnlyList<SeverityThresholdInfo> SeverityThresholds,
Dictionary<string, object?>? CustomMetadata,
string? ExtendsProfile,
DateTime ExportedAt);
/// <summary>
/// Severity threshold information for notification context.
/// </summary>
internal sealed record SeverityThresholdInfo(
string TargetSeverity,
Dictionary<string, object> WhenConditions);
#endregion

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Simulation;
@@ -12,6 +13,7 @@ internal static class RiskSimulationEndpoints
{
var group = endpoints.MapGroup("/api/risk/simulation")
.RequireAuthorization()
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithTags("Risk Simulation");
group.MapPost("/", RunSimulation)

View File

@@ -0,0 +1,48 @@
namespace StellaOps.Policy.Engine.Options;
/// <summary>
/// Rate limiting configuration for Policy Engine simulation endpoints.
/// </summary>
public sealed class PolicyEngineRateLimitOptions
{
/// <summary>
/// Configuration section name for binding.
/// </summary>
public const string SectionName = "RateLimiting";
/// <summary>
/// Maximum number of permits per window for simulation endpoints.
/// Default: 100 requests per window.
/// </summary>
public int SimulationPermitLimit { get; set; } = 100;
/// <summary>
/// Window duration in seconds for rate limiting.
/// Default: 60 seconds.
/// </summary>
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// Maximum number of requests that can be queued when the limit is reached.
/// Default: 10 requests.
/// </summary>
public int QueueLimit { get; set; } = 10;
/// <summary>
/// Whether to partition rate limits by tenant ID.
/// When enabled, each tenant gets their own rate limit bucket.
/// Default: true.
/// </summary>
public bool TenantPartitioning { get; set; } = true;
/// <summary>
/// Whether rate limiting is enabled.
/// Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Custom policy name for the simulation rate limiter.
/// </summary>
public const string PolicyName = "policy-simulation";
}

View File

@@ -1,27 +1,29 @@
using System.IO;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.DependencyInjection;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.DependencyInjection;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.AirGap.Policy;
using StellaOps.Policy.Engine.Orchestration;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
var builder = WebApplication.CreateBuilder(args);
@@ -113,10 +115,10 @@ builder.Services.AddOptions<PolicyEngineOptions>()
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<PolicyEngineOptions>().ExceptionLifecycle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddSingleton<PolicyTimelineEvents>();
builder.Services.AddSingleton<EvidenceBundleService>();
builder.Services.AddSingleton<PolicyEvaluationAttestationService>();
@@ -125,63 +127,102 @@ builder.Services.AddSingleton<RiskProfileConfigurationService>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.ScopeAttachmentService>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Overrides.OverrideService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
builder.Services.AddSingleton<ExceptionLifecycleService>();
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddPolicyEngineCore();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.RiskSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Signals.Entropy.EntropyPenaltyCalculator>();
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Export.ProfileExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.ProfileEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Events.IExceptionEventPublisher>(sp =>
new StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher(
sp.GetService<StellaOps.Policy.Engine.ExceptionCache.IExceptionEffectiveCache>(),
sp.GetRequiredService<ILogger<StellaOps.Policy.Engine.Events.LoggingExceptionEventPublisher>>()));
builder.Services.AddSingleton<ExceptionLifecycleService>();
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
builder.Services.AddHostedService<IncidentModeExpirationWorker>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Simulation.SimulationAnalyticsService>();
builder.Services.AddSingleton<ConsoleSimulationDiffService>();
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddPolicyEngineCore();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
builder.Services.AddSingleton<OrchestratorJobService>();
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
builder.Services.AddSingleton<PolicyWorkerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
builder.Services.AddSingleton<OrchestratorJobService>();
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
builder.Services.AddSingleton<PolicyWorkerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyDecisionService>();
builder.Services.AddSingleton<IExceptionRepository, InMemoryExceptionRepository>();
builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFactsStore>();
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
// Rate limiting configuration for simulation endpoints
var rateLimitOptions = builder.Configuration
.GetSection(PolicyEngineRateLimitOptions.SectionName)
.Get<PolicyEngineRateLimitOptions>() ?? new PolicyEngineRateLimitOptions();
if (rateLimitOptions.Enabled)
{
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddTokenBucketLimiter(PolicyEngineRateLimitOptions.PolicyName, limiterOptions =>
{
limiterOptions.TokenLimit = rateLimitOptions.SimulationPermitLimit;
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds);
limiterOptions.TokensPerPeriod = rateLimitOptions.SimulationPermitLimit;
limiterOptions.QueueLimit = rateLimitOptions.QueueLimit;
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.OnRejected = async (context, cancellationToken) =>
{
var tenant = context.HttpContext.User.FindFirst("tenant_id")?.Value;
var endpoint = context.HttpContext.Request.Path.Value;
PolicyEngineTelemetry.RecordRateLimitExceeded(tenant, endpoint);
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.RetryAfter = rateLimitOptions.WindowSeconds.ToString();
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "ERR_POL_007",
message = "Rate limit exceeded. Please retry after the reset window.",
retryAfterSeconds = rateLimitOptions.WindowSeconds
}, cancellationToken);
};
});
}
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
@@ -211,6 +252,11 @@ var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
@@ -220,16 +266,16 @@ app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.MapOverlaySimulation();
app.MapEvidenceSummaries();
app.MapBatchEvaluation();
app.MapConsoleSimulationDiff();
app.MapTrustWeighting();
app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.MapOverlaySimulation();
app.MapEvidenceSummaries();
app.MapBatchEvaluation();
app.MapConsoleSimulationDiff();
app.MapTrustWeighting();
app.MapAdvisoryAiKnobs();
app.MapBatchContext();
app.MapOrchestratorJobs();
app.MapPolicyWorker();
app.MapLedgerExport();

View File

@@ -55,12 +55,12 @@ public static class PolicyEngineTelemetry
unit: "overrides",
description: "Total number of VEX overrides applied during policy evaluation.");
// Counter: policy_compilation_total{outcome}
private static readonly Counter<long> PolicyCompilationCounter =
Meter.CreateCounter<long>(
"policy_compilation_total",
unit: "compilations",
description: "Total number of policy compilations attempted.");
// Counter: policy_compilation_total{outcome}
private static readonly Counter<long> PolicyCompilationCounter =
Meter.CreateCounter<long>(
"policy_compilation_total",
unit: "compilations",
description: "Total number of policy compilations attempted.");
// Histogram: policy_compilation_seconds
private static readonly Histogram<double> PolicyCompilationSecondsHistogram =
@@ -70,73 +70,95 @@ public static class PolicyEngineTelemetry
description: "Duration of policy compilation.");
// Counter: policy_simulation_total{tenant,outcome}
private static readonly Counter<long> PolicySimulationCounter =
Meter.CreateCounter<long>(
"policy_simulation_total",
unit: "simulations",
description: "Total number of policy simulations executed.");
#region Entropy Metrics
// Counter: policy_entropy_penalty_total{outcome}
private static readonly Counter<long> EntropyPenaltyCounter =
Meter.CreateCounter<long>(
"policy_entropy_penalty_total",
unit: "penalties",
description: "Total entropy penalties computed from scanner evidence.");
// Histogram: policy_entropy_penalty_value{outcome}
private static readonly Histogram<double> EntropyPenaltyHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_penalty_value",
unit: "ratio",
description: "Entropy penalty values (after cap).");
// Histogram: policy_entropy_image_opaque_ratio{outcome}
private static readonly Histogram<double> EntropyImageOpaqueRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_image_opaque_ratio",
unit: "ratio",
description: "Image opaque ratios observed in layer summaries.");
// Histogram: policy_entropy_top_file_ratio{outcome}
private static readonly Histogram<double> EntropyTopFileRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_top_file_ratio",
unit: "ratio",
description: "Opaque ratio of the top offending file when present.");
/// <summary>
/// Records an entropy penalty computation.
/// </summary>
public static void RecordEntropyPenalty(
double penalty,
string outcome,
double imageOpaqueRatio,
double? topFileOpaqueRatio = null)
{
var tags = new TagList
{
{ "outcome", NormalizeTag(outcome) },
};
EntropyPenaltyCounter.Add(1, tags);
EntropyPenaltyHistogram.Record(penalty, tags);
EntropyImageOpaqueRatioHistogram.Record(imageOpaqueRatio, tags);
if (topFileOpaqueRatio.HasValue)
{
EntropyTopFileRatioHistogram.Record(topFileOpaqueRatio.Value, tags);
}
}
#endregion
#region Golden Signals - Latency
// Histogram: policy_api_latency_seconds{endpoint,method,status}
private static readonly Histogram<double> ApiLatencyHistogram =
Meter.CreateHistogram<double>(
private static readonly Counter<long> PolicySimulationCounter =
Meter.CreateCounter<long>(
"policy_simulation_total",
unit: "simulations",
description: "Total number of policy simulations executed.");
// Counter: policy_rate_limit_exceeded_total{tenant,endpoint}
private static readonly Counter<long> RateLimitExceededCounter =
Meter.CreateCounter<long>(
"policy_rate_limit_exceeded_total",
unit: "requests",
description: "Total requests rejected due to rate limiting.");
/// <summary>
/// Records a rate limit exceeded event.
/// </summary>
/// <param name="tenant">The tenant ID (or "anonymous" if not available).</param>
/// <param name="endpoint">The endpoint that was rate limited.</param>
public static void RecordRateLimitExceeded(string? tenant = null, string? endpoint = null)
{
var tags = new TagList
{
{ "tenant", NormalizeTag(tenant ?? "anonymous") },
{ "endpoint", NormalizeTag(endpoint ?? "simulation") },
};
RateLimitExceededCounter.Add(1, tags);
}
#region Entropy Metrics
// Counter: policy_entropy_penalty_total{outcome}
private static readonly Counter<long> EntropyPenaltyCounter =
Meter.CreateCounter<long>(
"policy_entropy_penalty_total",
unit: "penalties",
description: "Total entropy penalties computed from scanner evidence.");
// Histogram: policy_entropy_penalty_value{outcome}
private static readonly Histogram<double> EntropyPenaltyHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_penalty_value",
unit: "ratio",
description: "Entropy penalty values (after cap).");
// Histogram: policy_entropy_image_opaque_ratio{outcome}
private static readonly Histogram<double> EntropyImageOpaqueRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_image_opaque_ratio",
unit: "ratio",
description: "Image opaque ratios observed in layer summaries.");
// Histogram: policy_entropy_top_file_ratio{outcome}
private static readonly Histogram<double> EntropyTopFileRatioHistogram =
Meter.CreateHistogram<double>(
"policy_entropy_top_file_ratio",
unit: "ratio",
description: "Opaque ratio of the top offending file when present.");
/// <summary>
/// Records an entropy penalty computation.
/// </summary>
public static void RecordEntropyPenalty(
double penalty,
string outcome,
double imageOpaqueRatio,
double? topFileOpaqueRatio = null)
{
var tags = new TagList
{
{ "outcome", NormalizeTag(outcome) },
};
EntropyPenaltyCounter.Add(1, tags);
EntropyPenaltyHistogram.Record(penalty, tags);
EntropyImageOpaqueRatioHistogram.Record(imageOpaqueRatio, tags);
if (topFileOpaqueRatio.HasValue)
{
EntropyTopFileRatioHistogram.Record(topFileOpaqueRatio.Value, tags);
}
}
#endregion
#region Golden Signals - Latency
// Histogram: policy_api_latency_seconds{endpoint,method,status}
private static readonly Histogram<double> ApiLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_api_latency_seconds",
unit: "s",
description: "API request latency by endpoint.");
@@ -419,33 +441,33 @@ public static class PolicyEngineTelemetry
/// </summary>
public static Counter<long> ExceptionOperations => ExceptionOperationsCounter;
// Counter: policy_exception_cache_operations_total{tenant,operation}
private static readonly Counter<long> ExceptionCacheOperationsCounter =
Meter.CreateCounter<long>(
"policy_exception_cache_operations_total",
unit: "operations",
description: "Total exception cache operations (hit, miss, set, warm, invalidate).");
// Counter: policy_exception_applications_total{tenant,effect}
private static readonly Counter<long> ExceptionApplicationsCounter =
Meter.CreateCounter<long>(
"policy_exception_applications_total",
unit: "applications",
description: "Total applied exceptions during evaluation by effect type.");
// Histogram: policy_exception_application_latency_seconds{tenant,effect}
private static readonly Histogram<double> ExceptionApplicationLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_exception_application_latency_seconds",
unit: "s",
description: "Latency impact of exception application during evaluation.");
// Counter: policy_exception_lifecycle_total{tenant,event}
private static readonly Counter<long> ExceptionLifecycleCounter =
Meter.CreateCounter<long>(
"policy_exception_lifecycle_total",
unit: "events",
description: "Lifecycle events for exceptions (activated, expired, revoked).");
// Counter: policy_exception_cache_operations_total{tenant,operation}
private static readonly Counter<long> ExceptionCacheOperationsCounter =
Meter.CreateCounter<long>(
"policy_exception_cache_operations_total",
unit: "operations",
description: "Total exception cache operations (hit, miss, set, warm, invalidate).");
// Counter: policy_exception_applications_total{tenant,effect}
private static readonly Counter<long> ExceptionApplicationsCounter =
Meter.CreateCounter<long>(
"policy_exception_applications_total",
unit: "applications",
description: "Total applied exceptions during evaluation by effect type.");
// Histogram: policy_exception_application_latency_seconds{tenant,effect}
private static readonly Histogram<double> ExceptionApplicationLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_exception_application_latency_seconds",
unit: "s",
description: "Latency impact of exception application during evaluation.");
// Counter: policy_exception_lifecycle_total{tenant,event}
private static readonly Counter<long> ExceptionLifecycleCounter =
Meter.CreateCounter<long>(
"policy_exception_lifecycle_total",
unit: "events",
description: "Lifecycle events for exceptions (activated, expired, revoked).");
/// <summary>
/// Counter for exception cache operations.
@@ -688,58 +710,58 @@ public static class PolicyEngineTelemetry
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="operation">Operation type (hit, miss, set, warm, invalidate_*, event_*).</param>
public static void RecordExceptionCacheOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionCacheOperationsCounter.Add(1, tags);
}
/// <summary>
/// Records that an exception was applied during evaluation.
/// </summary>
public static void RecordExceptionApplication(string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationsCounter.Add(1, tags);
}
/// <summary>
/// Records latency attributed to exception application during evaluation.
/// </summary>
public static void RecordExceptionApplicationLatency(double seconds, string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationLatencyHistogram.Record(seconds, tags);
}
/// <summary>
/// Records an exception lifecycle event (activated, expired, revoked).
/// </summary>
public static void RecordExceptionLifecycle(string tenant, string eventType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "event", NormalizeTag(eventType) },
};
ExceptionLifecycleCounter.Add(1, tags);
}
public static void RecordExceptionCacheOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionCacheOperationsCounter.Add(1, tags);
}
/// <summary>
/// Records that an exception was applied during evaluation.
/// </summary>
public static void RecordExceptionApplication(string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationsCounter.Add(1, tags);
}
/// <summary>
/// Records latency attributed to exception application during evaluation.
/// </summary>
public static void RecordExceptionApplicationLatency(double seconds, string tenant, string effectType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "effect", NormalizeTag(effectType) },
};
ExceptionApplicationLatencyHistogram.Record(seconds, tags);
}
/// <summary>
/// Records an exception lifecycle event (activated, expired, revoked).
/// </summary>
public static void RecordExceptionLifecycle(string tenant, string eventType)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "event", NormalizeTag(eventType) },
};
ExceptionLifecycleCounter.Add(1, tags);
}
#region Golden Signals - Recording Methods

View File

@@ -0,0 +1,119 @@
-- Policy Schema Migration 003: Snapshots, Violations, Conflicts, Ledger Exports
-- Adds tables for policy snapshots, violation events, conflict handling, and ledger exports
-- Snapshots table (immutable policy configuration snapshots)
CREATE TABLE IF NOT EXISTS policy.snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL,
version INT NOT NULL,
content_digest TEXT NOT NULL,
content JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, policy_id, version)
);
CREATE INDEX idx_snapshots_tenant ON policy.snapshots(tenant_id);
CREATE INDEX idx_snapshots_policy ON policy.snapshots(tenant_id, policy_id);
CREATE INDEX idx_snapshots_digest ON policy.snapshots(content_digest);
-- Violation events table (append-only)
CREATE TABLE IF NOT EXISTS policy.violation_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL,
rule_id TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
subject_purl TEXT,
subject_cve TEXT,
details JSONB NOT NULL DEFAULT '{}',
remediation TEXT,
correlation_id TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Append-only: no UPDATE trigger, only INSERTs allowed
CREATE INDEX idx_violation_events_tenant ON policy.violation_events(tenant_id);
CREATE INDEX idx_violation_events_policy ON policy.violation_events(tenant_id, policy_id);
CREATE INDEX idx_violation_events_rule ON policy.violation_events(rule_id);
CREATE INDEX idx_violation_events_severity ON policy.violation_events(severity);
CREATE INDEX idx_violation_events_purl ON policy.violation_events(subject_purl) WHERE subject_purl IS NOT NULL;
CREATE INDEX idx_violation_events_occurred ON policy.violation_events(tenant_id, occurred_at);
-- Conflicts table (for conflict detection and resolution)
CREATE TABLE IF NOT EXISTS policy.conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
conflict_type TEXT NOT NULL CHECK (conflict_type IN ('rule_overlap', 'scope_collision', 'version_mismatch', 'precedence', 'other')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
left_rule_id TEXT,
right_rule_id TEXT,
affected_scope TEXT,
description TEXT NOT NULL,
resolution TEXT,
resolved_by TEXT,
resolved_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_conflicts_tenant ON policy.conflicts(tenant_id);
CREATE INDEX idx_conflicts_status ON policy.conflicts(tenant_id, status);
CREATE INDEX idx_conflicts_type ON policy.conflicts(conflict_type);
-- Ledger exports table (for tracking ledger exports)
CREATE TABLE IF NOT EXISTS policy.ledger_exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
export_type TEXT NOT NULL CHECK (export_type IN ('full', 'incremental', 'snapshot')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
format TEXT NOT NULL DEFAULT 'ndjson' CHECK (format IN ('ndjson', 'json', 'parquet', 'csv')),
content_digest TEXT,
record_count INT,
byte_size BIGINT,
storage_path TEXT,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
error_message TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_ledger_exports_tenant ON policy.ledger_exports(tenant_id);
CREATE INDEX idx_ledger_exports_status ON policy.ledger_exports(status);
CREATE INDEX idx_ledger_exports_digest ON policy.ledger_exports(content_digest) WHERE content_digest IS NOT NULL;
CREATE INDEX idx_ledger_exports_created ON policy.ledger_exports(tenant_id, created_at);
-- Worker results table (for background job tracking)
CREATE TABLE IF NOT EXISTS policy.worker_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
job_type TEXT NOT NULL,
job_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
input_hash TEXT,
output_hash TEXT,
progress INT DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
result JSONB,
error_message TEXT,
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, job_type, job_id)
);
CREATE INDEX idx_worker_results_tenant ON policy.worker_results(tenant_id);
CREATE INDEX idx_worker_results_status ON policy.worker_results(status);
CREATE INDEX idx_worker_results_job_type ON policy.worker_results(job_type);
CREATE INDEX idx_worker_results_scheduled ON policy.worker_results(scheduled_at) WHERE status = 'pending';

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a policy conflict for resolution.
/// </summary>
public sealed record ConflictEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string ConflictType { get; init; }
public string Status { get; init; } = "open";
public string Severity { get; init; } = "medium";
public string? LeftRuleId { get; init; }
public string? RightRuleId { get; init; }
public string? AffectedScope { get; init; }
public required string Description { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a ledger export operation.
/// </summary>
public sealed record LedgerExportEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string ExportType { get; init; }
public string Status { get; init; } = "pending";
public string Format { get; init; } = "ndjson";
public string? ContentDigest { get; init; }
public int? RecordCount { get; init; }
public long? ByteSize { get; init; }
public string? StoragePath { get; init; }
public DateTimeOffset? StartTime { get; init; }
public DateTimeOffset? EndTime { get; init; }
public string? ErrorMessage { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an immutable policy configuration snapshot.
/// </summary>
public sealed record SnapshotEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid PolicyId { get; init; }
public int Version { get; init; }
public required string ContentDigest { get; init; }
public required string Content { get; init; }
public string Metadata { get; init; } = "{}";
public required string CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an append-only violation event.
/// </summary>
public sealed record ViolationEventEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid PolicyId { get; init; }
public required string RuleId { get; init; }
public required string Severity { get; init; }
public string? SubjectPurl { get; init; }
public string? SubjectCve { get; init; }
public string Details { get; init; } = "{}";
public string? Remediation { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a background worker job result.
/// </summary>
public sealed record WorkerResultEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string JobType { get; init; }
public required string JobId { get; init; }
public string Status { get; init; } = "pending";
public string? InputHash { get; init; }
public string? OutputHash { get; init; }
public int Progress { get; init; }
public string? Result { get; init; }
public string? ErrorMessage { get; init; }
public int RetryCount { get; init; }
public int MaxRetries { get; init; } = 3;
public DateTimeOffset? ScheduledAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,258 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for conflict detection and resolution operations.
/// </summary>
public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConflictRepository
{
/// <summary>
/// Creates a new conflict repository.
/// </summary>
public ConflictRepository(PolicyDataSource dataSource, ILogger<ConflictRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.conflicts (
id, tenant_id, conflict_type, severity, status, left_rule_id,
right_rule_id, affected_scope, description, metadata, created_by
)
VALUES (
@id, @tenant_id, @conflict_type, @severity, @status, @left_rule_id,
@right_rule_id, @affected_scope, @description, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(conflict.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddConflictParameters(command, conflict);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapConflict(reader);
}
/// <inheritdoc />
public async Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.conflicts WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
created_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
string tenantId,
string conflictType,
string? status = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND conflict_type = @conflict_type
""";
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "conflict_type", conflictType);
AddParameter(cmd, "limit", limit);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> ResolveAsync(
string tenantId,
Guid id,
string resolution,
string resolvedBy,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.conflicts
SET status = 'resolved', resolution = @resolution, resolved_by = @resolved_by, resolved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "resolution", resolution);
AddParameter(cmd, "resolved_by", resolvedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.conflicts
SET status = 'dismissed', resolved_by = @dismissed_by, resolved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "dismissed_by", dismissedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<Dictionary<string, int>> CountOpenBySeverityAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
GROUP BY severity
""";
var results = new Dictionary<string, int>();
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var severity = reader.GetString(reader.GetOrdinal("severity"));
var count = reader.GetInt32(reader.GetOrdinal("count"));
results[severity] = count;
}
return results;
}
private static void AddConflictParameters(NpgsqlCommand command, ConflictEntity conflict)
{
AddParameter(command, "id", conflict.Id);
AddParameter(command, "tenant_id", conflict.TenantId);
AddParameter(command, "conflict_type", conflict.ConflictType);
AddParameter(command, "severity", conflict.Severity);
AddParameter(command, "status", conflict.Status);
AddParameter(command, "left_rule_id", conflict.LeftRuleId as object ?? DBNull.Value);
AddParameter(command, "right_rule_id", conflict.RightRuleId as object ?? DBNull.Value);
AddParameter(command, "affected_scope", conflict.AffectedScope as object ?? DBNull.Value);
AddParameter(command, "description", conflict.Description);
AddJsonbParameter(command, "metadata", conflict.Metadata);
AddParameter(command, "created_by", conflict.CreatedBy as object ?? DBNull.Value);
}
private static ConflictEntity MapConflict(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ConflictType = reader.GetString(reader.GetOrdinal("conflict_type")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
Status = reader.GetString(reader.GetOrdinal("status")),
LeftRuleId = GetNullableString(reader, reader.GetOrdinal("left_rule_id")),
RightRuleId = GetNullableString(reader, reader.GetOrdinal("right_rule_id")),
AffectedScope = GetNullableString(reader, reader.GetOrdinal("affected_scope")),
Description = reader.GetString(reader.GetOrdinal("description")),
Resolution = GetNullableString(reader, reader.GetOrdinal("resolution")),
ResolvedBy = GetNullableString(reader, reader.GetOrdinal("resolved_by")),
ResolvedAt = reader.IsDBNull(reader.GetOrdinal("resolved_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("resolved_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for conflict detection and resolution operations.
/// </summary>
public interface IConflictRepository
{
/// <summary>
/// Creates a new conflict.
/// </summary>
Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a conflict by ID.
/// </summary>
Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all open conflicts for a tenant.
/// </summary>
Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets conflicts by type.
/// </summary>
Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
string tenantId,
string conflictType,
string? status = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a conflict.
/// </summary>
Task<bool> ResolveAsync(
string tenantId,
Guid id,
string resolution,
string resolvedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Dismisses a conflict.
/// </summary>
Task<bool> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts open conflicts by severity.
/// </summary>
Task<Dictionary<string, int>> CountOpenBySeverityAsync(
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for ledger export operations.
/// </summary>
public interface ILedgerExportRepository
{
/// <summary>
/// Creates a new ledger export.
/// </summary>
Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ledger export by ID.
/// </summary>
Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ledger export by content digest.
/// </summary>
Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all ledger exports for a tenant.
/// </summary>
Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
string tenantId,
string? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status of a ledger export.
/// </summary>
Task<bool> UpdateStatusAsync(
string tenantId,
Guid id,
string status,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a ledger export with results.
/// </summary>
Task<bool> CompleteAsync(
string tenantId,
Guid id,
string contentDigest,
int recordCount,
long byteSize,
string? storagePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest completed export for a tenant.
/// </summary>
Task<LedgerExportEntity?> GetLatestCompletedAsync(
string tenantId,
string? exportType = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy snapshot operations.
/// </summary>
public interface ISnapshotRepository
{
/// <summary>
/// Creates a new snapshot.
/// </summary>
Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a snapshot by ID.
/// </summary>
Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest snapshot for a policy.
/// </summary>
Task<SnapshotEntity?> GetLatestByPolicyAsync(string tenantId, Guid policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a snapshot by content digest.
/// </summary>
Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all snapshots for a policy.
/// </summary>
Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a snapshot.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,63 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for append-only violation event operations.
/// </summary>
public interface IViolationEventRepository
{
/// <summary>
/// Appends a new violation event (immutable).
/// </summary>
Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default);
/// <summary>
/// Appends multiple violation events (immutable).
/// </summary>
Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a violation event by ID.
/// </summary>
Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events for a policy.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
DateTimeOffset? since = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events by severity.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
string tenantId,
string severity,
DateTimeOffset? since = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events for a PURL.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
string tenantId,
string purl,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts violations by severity for a time range.
/// </summary>
Task<Dictionary<string, int>> CountBySeverityAsync(
string tenantId,
DateTimeOffset since,
DateTimeOffset until,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,83 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for worker result operations.
/// </summary>
public interface IWorkerResultRepository
{
/// <summary>
/// Creates a new worker result.
/// </summary>
Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a worker result by ID.
/// </summary>
Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a worker result by job type and job ID.
/// </summary>
Task<WorkerResultEntity?> GetByJobAsync(
string tenantId,
string jobType,
string jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets worker results by status.
/// </summary>
Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
string tenantId,
string status,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending worker results ready for execution.
/// </summary>
Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
string? jobType = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status and progress of a worker result.
/// </summary>
Task<bool> UpdateProgressAsync(
string tenantId,
Guid id,
string status,
int progress,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a worker result with the final result.
/// </summary>
Task<bool> CompleteAsync(
string tenantId,
Guid id,
string result,
string? outputHash = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a worker result as failed.
/// </summary>
Task<bool> FailAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments the retry count for a worker result.
/// </summary>
Task<bool> IncrementRetryAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,253 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for ledger export operations.
/// </summary>
public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, ILedgerExportRepository
{
/// <summary>
/// Creates a new ledger export repository.
/// </summary>
public LedgerExportRepository(PolicyDataSource dataSource, ILogger<LedgerExportRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.ledger_exports (
id, tenant_id, export_type, status, format, metadata, created_by
)
VALUES (
@id, @tenant_id, @export_type, @status, @format, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(export.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddExportParameters(command, export);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapExport(reader);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapExport(reader);
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
string tenantId,
string? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id";
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateStatusAsync(
string tenantId,
Guid id,
string status,
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.ledger_exports
SET status = @status, error_message = @error_message,
start_time = CASE WHEN @status = 'running' AND start_time IS NULL THEN NOW() ELSE start_time END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "status", status);
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> CompleteAsync(
string tenantId,
Guid id,
string contentDigest,
int recordCount,
long byteSize,
string? storagePath,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.ledger_exports
SET status = 'completed',
content_digest = @content_digest,
record_count = @record_count,
byte_size = @byte_size,
storage_path = @storage_path,
end_time = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "content_digest", contentDigest);
AddParameter(cmd, "record_count", recordCount);
AddParameter(cmd, "byte_size", byteSize);
AddParameter(cmd, "storage_path", storagePath as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetLatestCompletedAsync(
string tenantId,
string? exportType = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.ledger_exports
WHERE tenant_id = @tenant_id AND status = 'completed'
""";
if (!string.IsNullOrEmpty(exportType))
{
sql += " AND export_type = @export_type";
}
sql += " ORDER BY end_time DESC LIMIT 1";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (!string.IsNullOrEmpty(exportType))
{
AddParameter(cmd, "export_type", exportType);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
private static void AddExportParameters(NpgsqlCommand command, LedgerExportEntity export)
{
AddParameter(command, "id", export.Id);
AddParameter(command, "tenant_id", export.TenantId);
AddParameter(command, "export_type", export.ExportType);
AddParameter(command, "status", export.Status);
AddParameter(command, "format", export.Format);
AddJsonbParameter(command, "metadata", export.Metadata);
AddParameter(command, "created_by", export.CreatedBy as object ?? DBNull.Value);
}
private static LedgerExportEntity MapExport(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ExportType = reader.GetString(reader.GetOrdinal("export_type")),
Status = reader.GetString(reader.GetOrdinal("status")),
Format = reader.GetString(reader.GetOrdinal("format")),
ContentDigest = GetNullableString(reader, reader.GetOrdinal("content_digest")),
RecordCount = reader.IsDBNull(reader.GetOrdinal("record_count"))
? null
: reader.GetInt32(reader.GetOrdinal("record_count")),
ByteSize = reader.IsDBNull(reader.GetOrdinal("byte_size"))
? null
: reader.GetInt64(reader.GetOrdinal("byte_size")),
StoragePath = GetNullableString(reader, reader.GetOrdinal("storage_path")),
StartTime = reader.IsDBNull(reader.GetOrdinal("start_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("start_time")),
EndTime = reader.IsDBNull(reader.GetOrdinal("end_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("end_time")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,179 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy snapshot operations.
/// </summary>
public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnapshotRepository
{
/// <summary>
/// Creates a new snapshot repository.
/// </summary>
public SnapshotRepository(PolicyDataSource dataSource, ILogger<SnapshotRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.snapshots (
id, tenant_id, policy_id, version, content_digest, content,
created_by, metadata
)
VALUES (
@id, @tenant_id, @policy_id, @version, @content_digest, @content::jsonb,
@created_by, @metadata::jsonb
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddSnapshotParameters(command, snapshot);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSnapshot(reader);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetLatestByPolicyAsync(
string tenantId,
Guid policyId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapSnapshot(reader);
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddSnapshotParameters(NpgsqlCommand command, SnapshotEntity snapshot)
{
AddParameter(command, "id", snapshot.Id);
AddParameter(command, "tenant_id", snapshot.TenantId);
AddParameter(command, "policy_id", snapshot.PolicyId);
AddParameter(command, "version", snapshot.Version);
AddParameter(command, "content_digest", snapshot.ContentDigest);
AddParameter(command, "content", snapshot.Content);
AddParameter(command, "created_by", snapshot.CreatedBy);
AddJsonbParameter(command, "metadata", snapshot.Metadata);
}
private static SnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
ContentDigest = reader.GetString(reader.GetOrdinal("content_digest")),
Content = reader.GetString(reader.GetOrdinal("content")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
};
}

View File

@@ -0,0 +1,265 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for append-only violation event operations.
/// </summary>
public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>, IViolationEventRepository
{
/// <summary>
/// Creates a new violation event repository.
/// </summary>
public ViolationEventRepository(PolicyDataSource dataSource, ILogger<ViolationEventRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(violationEvent.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, violationEvent);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapViolation(reader);
}
/// <inheritdoc />
public async Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default)
{
var eventList = events.ToList();
if (eventList.Count == 0) return 0;
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
""";
var tenantId = eventList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
var count = 0;
foreach (var evt in eventList)
{
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, evt);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
return count;
}
/// <inheritdoc />
public async Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.violation_events WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
DateTimeOffset? since = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
""";
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
}
sql += " ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
string tenantId,
string severity,
DateTimeOffset? since = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND severity = @severity
""";
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
}
sql += " ORDER BY occurred_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
string tenantId,
string purl,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND subject_purl = @purl
ORDER BY occurred_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "purl", purl);
AddParameter(cmd, "limit", limit);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Dictionary<string, int>> CountBySeverityAsync(
string tenantId,
DateTimeOffset since,
DateTimeOffset until,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.violation_events
WHERE tenant_id = @tenant_id AND occurred_at >= @since AND occurred_at < @until
GROUP BY severity
""";
var results = new Dictionary<string, int>();
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "since", since);
AddParameter(command, "until", until);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var severity = reader.GetString(reader.GetOrdinal("severity"));
var count = reader.GetInt32(reader.GetOrdinal("count"));
results[severity] = count;
}
return results;
}
private static void AddViolationParameters(NpgsqlCommand command, ViolationEventEntity violation)
{
AddParameter(command, "id", violation.Id);
AddParameter(command, "tenant_id", violation.TenantId);
AddParameter(command, "policy_id", violation.PolicyId);
AddParameter(command, "rule_id", violation.RuleId);
AddParameter(command, "severity", violation.Severity);
AddParameter(command, "subject_purl", violation.SubjectPurl as object ?? DBNull.Value);
AddParameter(command, "subject_cve", violation.SubjectCve as object ?? DBNull.Value);
AddJsonbParameter(command, "details", violation.Details);
AddParameter(command, "remediation", violation.Remediation as object ?? DBNull.Value);
AddParameter(command, "correlation_id", violation.CorrelationId as object ?? DBNull.Value);
AddParameter(command, "occurred_at", violation.OccurredAt);
}
private static ViolationEventEntity MapViolation(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
RuleId = reader.GetString(reader.GetOrdinal("rule_id")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
SubjectPurl = GetNullableString(reader, reader.GetOrdinal("subject_purl")),
SubjectCve = GetNullableString(reader, reader.GetOrdinal("subject_cve")),
Details = reader.GetString(reader.GetOrdinal("details")),
Remediation = GetNullableString(reader, reader.GetOrdinal("remediation")),
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
}

View File

@@ -0,0 +1,310 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for worker result operations.
/// </summary>
public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, IWorkerResultRepository
{
/// <summary>
/// Creates a new worker result repository.
/// </summary>
public WorkerResultRepository(PolicyDataSource dataSource, ILogger<WorkerResultRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.worker_results (
id, tenant_id, job_type, job_id, status, progress,
input_hash, max_retries, scheduled_at, metadata, created_by
)
VALUES (
@id, @tenant_id, @job_type, @job_id, @status, @progress,
@input_hash, @max_retries, @scheduled_at, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(result.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddResultParameters(command, result);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapResult(reader);
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.worker_results WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByJobAsync(
string tenantId,
string jobType,
string jobId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND job_type = @job_type AND job_id = @job_id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "job_type", jobType);
AddParameter(cmd, "job_id", jobId);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
string tenantId,
string status,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", status);
AddParameter(cmd, "limit", limit);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
string? jobType = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.worker_results
WHERE status = 'pending'
""";
if (!string.IsNullOrEmpty(jobType))
{
sql += " AND job_type = @job_type";
}
sql += " ORDER BY scheduled_at ASC NULLS LAST, created_at ASC LIMIT @limit";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
if (!string.IsNullOrEmpty(jobType))
{
AddParameter(command, "job_type", jobType);
}
var results = new List<WorkerResultEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapResult(reader));
}
return results;
}
/// <inheritdoc />
public async Task<bool> UpdateProgressAsync(
string tenantId,
Guid id,
string status,
int progress,
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = @status, progress = @progress, error_message = @error_message,
started_at = CASE WHEN @status = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "status", status);
AddParameter(cmd, "progress", progress);
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> CompleteAsync(
string tenantId,
Guid id,
string result,
string? outputHash = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = 'completed', progress = 100, result = @result::jsonb,
output_hash = @output_hash, completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "result", result);
AddParameter(cmd, "output_hash", outputHash as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> FailAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = 'failed', error_message = @error_message, completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "error_message", errorMessage);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> IncrementRetryAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET retry_count = retry_count + 1, status = 'pending', started_at = NULL
WHERE tenant_id = @tenant_id AND id = @id AND retry_count < max_retries
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddResultParameters(NpgsqlCommand command, WorkerResultEntity result)
{
AddParameter(command, "id", result.Id);
AddParameter(command, "tenant_id", result.TenantId);
AddParameter(command, "job_type", result.JobType);
AddParameter(command, "job_id", result.JobId);
AddParameter(command, "status", result.Status);
AddParameter(command, "progress", result.Progress);
AddParameter(command, "input_hash", result.InputHash as object ?? DBNull.Value);
AddParameter(command, "max_retries", result.MaxRetries);
AddParameter(command, "scheduled_at", result.ScheduledAt as object ?? DBNull.Value);
AddJsonbParameter(command, "metadata", result.Metadata);
AddParameter(command, "created_by", result.CreatedBy as object ?? DBNull.Value);
}
private static WorkerResultEntity MapResult(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
JobType = reader.GetString(reader.GetOrdinal("job_type")),
JobId = reader.GetString(reader.GetOrdinal("job_id")),
Status = reader.GetString(reader.GetOrdinal("status")),
Progress = reader.GetInt32(reader.GetOrdinal("progress")),
Result = GetNullableString(reader, reader.GetOrdinal("result")),
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
OutputHash = GetNullableString(reader, reader.GetOrdinal("output_hash")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
RetryCount = reader.GetInt32(reader.GetOrdinal("retry_count")),
MaxRetries = reader.GetInt32(reader.GetOrdinal("max_retries")),
ScheduledAt = reader.IsDBNull(reader.GetOrdinal("scheduled_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("scheduled_at")),
StartedAt = reader.IsDBNull(reader.GetOrdinal("started_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("started_at")),
CompletedAt = reader.IsDBNull(reader.GetOrdinal("completed_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("completed_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -37,6 +37,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
return services;
}
@@ -64,6 +69,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
return services;
}