up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
internal sealed record AdvisoryAiKnob(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("default_value")] decimal DefaultValue,
|
||||
[property: JsonPropertyName("min")] decimal Min,
|
||||
[property: JsonPropertyName("max")] decimal Max,
|
||||
[property: JsonPropertyName("step")] decimal Step,
|
||||
[property: JsonPropertyName("description")] string Description);
|
||||
|
||||
internal sealed record AdvisoryAiKnobsProfile(
|
||||
[property: JsonPropertyName("knobs")] IReadOnlyList<AdvisoryAiKnob> Knobs,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash);
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store for Advisory AI knobs (POLICY-ENGINE-31-001).
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryAiKnobsService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
private AdvisoryAiKnobsProfile _current;
|
||||
|
||||
public AdvisoryAiKnobsService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_current = BuildProfile(DefaultKnobs());
|
||||
}
|
||||
|
||||
public AdvisoryAiKnobsProfile Get() => _current;
|
||||
|
||||
public AdvisoryAiKnobsProfile Set(IReadOnlyList<AdvisoryAiKnob> knobs)
|
||||
{
|
||||
var normalized = Normalize(knobs);
|
||||
var profile = BuildProfile(normalized);
|
||||
lock (_lock)
|
||||
{
|
||||
_current = profile;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private AdvisoryAiKnobsProfile BuildProfile(IReadOnlyList<AdvisoryAiKnob> knobs)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(knobs, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
return new AdvisoryAiKnobsProfile(knobs, hash);
|
||||
}
|
||||
|
||||
private IReadOnlyList<AdvisoryAiKnob> Normalize(IReadOnlyList<AdvisoryAiKnob> knobs)
|
||||
{
|
||||
var normalized = knobs
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k.Name))
|
||||
.Select(k => new AdvisoryAiKnob(
|
||||
Name: k.Name.Trim().ToLowerInvariant(),
|
||||
DefaultValue: k.DefaultValue,
|
||||
Min: k.Min,
|
||||
Max: k.Max,
|
||||
Step: k.Step <= 0 ? 0.001m : k.Step,
|
||||
Description: string.IsNullOrWhiteSpace(k.Description) ? string.Empty : k.Description.Trim()))
|
||||
.OrderBy(k => k.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryAiKnob> DefaultKnobs() =>
|
||||
new[]
|
||||
{
|
||||
new AdvisoryAiKnob("ai_signal_weight", 1.0m, 0m, 2m, 0.01m, "Weight applied to AI signals"),
|
||||
new AdvisoryAiKnob("reachability_boost", 0.2m, 0m, 1m, 0.01m, "Boost when asset is reachable"),
|
||||
new AdvisoryAiKnob("time_decay_half_life_days", 30m, 1m, 365m, 1m, "Half-life for decay"),
|
||||
new AdvisoryAiKnob("evidence_freshness_threshold_hours", 72m, 1m, 720m, 1m, "Max evidence age")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.BatchContext;
|
||||
|
||||
internal sealed record BatchContextRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("knobs_version")] string KnobsVersion,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<BatchContextItem> Items,
|
||||
[property: JsonPropertyName("options")] BatchContextOptions Options);
|
||||
|
||||
internal sealed record BatchContextItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId);
|
||||
|
||||
internal sealed record BatchContextOptions(
|
||||
[property: JsonPropertyName("include_reachability")] bool IncludeReachability);
|
||||
|
||||
internal sealed record BatchContextResponse(
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("expires_at")] string ExpiresAt,
|
||||
[property: JsonPropertyName("knobs_version")] string KnobsVersion,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<BatchContextResolvedItem> Items);
|
||||
|
||||
internal sealed record BatchContextResolvedItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef);
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.BatchContext;
|
||||
|
||||
/// <summary>
|
||||
/// Creates deterministic batch context responses for advisory AI (POLICY-ENGINE-31-002).
|
||||
/// </summary>
|
||||
internal sealed class BatchContextService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public BatchContextService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public BatchContextResponse Create(BatchContextRequest request)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("items are required", nameof(request.Items));
|
||||
}
|
||||
|
||||
var sortedItems = request.Items
|
||||
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var status = request.Options?.IncludeReachability == true ? "pending-reachability" : "pending";
|
||||
var resolved = sortedItems
|
||||
.Select(i => new BatchContextResolvedItem(
|
||||
i.ComponentPurl,
|
||||
i.AdvisoryId,
|
||||
status,
|
||||
ComputeTraceRef(request.TenantId, i)))
|
||||
.ToList();
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O");
|
||||
var contextId = ComputeContextId(request, sortedItems);
|
||||
|
||||
return new BatchContextResponse(
|
||||
contextId,
|
||||
ExpiresAt: expires,
|
||||
KnobsVersion: request.KnobsVersion,
|
||||
OverlayHash: request.OverlayHash,
|
||||
Items: resolved);
|
||||
}
|
||||
|
||||
private static string ComputeContextId(BatchContextRequest request, IReadOnlyList<BatchContextItem> sortedItems)
|
||||
{
|
||||
var canonical = new
|
||||
{
|
||||
tenant = request.TenantId,
|
||||
profile = request.PolicyProfileHash,
|
||||
knobs = request.KnobsVersion,
|
||||
overlay = request.OverlayHash,
|
||||
items = sortedItems.Select(i => new { i.ComponentPurl, i.AdvisoryId }).ToArray(),
|
||||
includeReachability = request.Options?.IncludeReachability ?? false
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
Span<byte> hash = stackalloc byte[16];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(json), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static string ComputeTraceRef(string tenant, BatchContextItem item)
|
||||
{
|
||||
var stable = $"{tenant}|{item.ComponentPurl}|{item.AdvisoryId}";
|
||||
Span<byte> hash = stackalloc byte[12];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class AdvisoryAiKnobsEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdvisoryAiKnobs(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/policy/advisory-ai/knobs", GetAsync)
|
||||
.WithName("PolicyEngine.AdvisoryAI.Knobs.Get");
|
||||
|
||||
routes.MapPut("/policy/advisory-ai/knobs", PutAsync)
|
||||
.WithName("PolicyEngine.AdvisoryAI.Knobs.Put");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetAsync(AdvisoryAiKnobsService service)
|
||||
{
|
||||
var profile = service.Get();
|
||||
return Results.Json(profile);
|
||||
}
|
||||
|
||||
private static IResult PutAsync(
|
||||
[FromBody] IReadOnlyList<AdvisoryAiKnob> knobs,
|
||||
AdvisoryAiKnobsService service)
|
||||
{
|
||||
if (knobs is null || knobs.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "knobs are required" });
|
||||
}
|
||||
|
||||
var profile = service.Set(knobs);
|
||||
return Results.Json(profile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.BatchContext;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class BatchContextEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBatchContext(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/batch/context", HandleAsync)
|
||||
.WithName("PolicyEngine.BatchContext.Create");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult HandleAsync(
|
||||
[FromBody] BatchContextRequest request,
|
||||
BatchContextService service)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = service.Create(request);
|
||||
return Results.Json(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class LedgerExportEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLedgerExport(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/ledger/export", BuildAsync)
|
||||
.WithName("PolicyEngine.Ledger.Export");
|
||||
|
||||
routes.MapGet("/policy/ledger/export/{exportId}", GetAsync)
|
||||
.WithName("PolicyEngine.Ledger.GetExport");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> BuildAsync(
|
||||
[FromBody] LedgerExportRequest request,
|
||||
LedgerExportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var export = await service.BuildAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(export);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(
|
||||
[FromRoute] string exportId,
|
||||
LedgerExportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var export = await service.GetAsync(exportId, cancellationToken).ConfigureAwait(false);
|
||||
return export is null ? Results.NotFound() : Results.Json(export);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class OrchestratorJobEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOrchestratorJobs(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/orchestrator/jobs", SubmitAsync)
|
||||
.WithName("PolicyEngine.Orchestrator.Jobs.Submit");
|
||||
|
||||
routes.MapPost("/policy/orchestrator/jobs/preview", PreviewAsync)
|
||||
.WithName("PolicyEngine.Orchestrator.Jobs.Preview");
|
||||
|
||||
routes.MapGet("/policy/orchestrator/jobs/{jobId}", GetAsync)
|
||||
.WithName("PolicyEngine.Orchestrator.Jobs.Get");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SubmitAsync(
|
||||
[FromBody] OrchestratorJobRequest request,
|
||||
OrchestratorJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await service.SubmitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(job);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewAsync(
|
||||
[FromBody] OrchestratorJobRequest request,
|
||||
OrchestratorJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await service.PreviewAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(job);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(
|
||||
[FromRoute] string jobId,
|
||||
OrchestratorJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await service.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return job is null ? Results.NotFound() : Results.Json(job);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
@@ -19,6 +20,7 @@ public static class PathScopeSimulationEndpoint
|
||||
private static async Task<IResult> HandleAsync(
|
||||
[FromBody] PathScopeSimulationRequest request,
|
||||
PathScopeSimulationService service,
|
||||
PathScopeSimulationBridgeService bridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -31,6 +33,19 @@ public static class PathScopeSimulationEndpoint
|
||||
responseBuilder.AppendLine(line);
|
||||
}
|
||||
|
||||
// Emit change event stub when run in what-if mode.
|
||||
if (request.Options.Deterministic && request.Options.IncludeTrace)
|
||||
{
|
||||
var bridgeRequest = new PathScopeSimulationBridgeRequest(
|
||||
Tenant: request.Tenant,
|
||||
Rules: Array.Empty<string>(),
|
||||
Overlays: null,
|
||||
Paths: new[] { request },
|
||||
Mode: "preview",
|
||||
Seed: null);
|
||||
await bridge.SimulateAsync(bridgeRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Text(responseBuilder.ToString(), "application/x-ndjson", Encoding.UTF8);
|
||||
}
|
||||
catch (PathScopeSimulationException ex)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class PolicyWorkerEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyWorker(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/worker/run", RunAsync)
|
||||
.WithName("PolicyEngine.Worker.Run");
|
||||
|
||||
routes.MapGet("/policy/worker/jobs/{jobId}", GetResultAsync)
|
||||
.WithName("PolicyEngine.Worker.GetResult");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> RunAsync(
|
||||
[FromBody] WorkerRunRequest request,
|
||||
PolicyWorkerService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetResultAsync(
|
||||
[FromRoute] string jobId,
|
||||
IWorkerResultStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await store.GetByJobIdAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return result is null ? Results.NotFound() : Results.Json(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class SnapshotEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSnapshots(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/snapshots", CreateAsync)
|
||||
.WithName("PolicyEngine.Snapshots.Create");
|
||||
|
||||
routes.MapGet("/policy/snapshots", ListAsync)
|
||||
.WithName("PolicyEngine.Snapshots.List");
|
||||
|
||||
routes.MapGet("/policy/snapshots/{snapshotId}", GetAsync)
|
||||
.WithName("PolicyEngine.Snapshots.Get");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAsync(
|
||||
[FromBody] SnapshotRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await service.CreateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(snapshot);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(
|
||||
[FromQuery(Name = "tenant_id")] string? tenantId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (items, cursor) = await service.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { items, next_cursor = cursor });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(
|
||||
[FromRoute] string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = await service.GetAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
return snapshot is null ? Results.NotFound() : Results.Json(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class TrustWeightingEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTrustWeighting(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/policy/trust-weighting", GetAsync)
|
||||
.WithName("PolicyEngine.TrustWeighting.Get");
|
||||
|
||||
routes.MapPut("/policy/trust-weighting", PutAsync)
|
||||
.WithName("PolicyEngine.TrustWeighting.Put");
|
||||
|
||||
routes.MapGet("/policy/trust-weighting/preview", PreviewAsync)
|
||||
.WithName("PolicyEngine.TrustWeighting.Preview");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetAsync(TrustWeightingService service)
|
||||
{
|
||||
var profile = service.Get();
|
||||
return Results.Json(profile);
|
||||
}
|
||||
|
||||
private static IResult PutAsync(
|
||||
[FromBody] IReadOnlyList<TrustWeightingEntry> weights,
|
||||
TrustWeightingService service)
|
||||
{
|
||||
if (weights is null || weights.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "weights are required" });
|
||||
}
|
||||
|
||||
var profile = service.Set(weights);
|
||||
return Results.Json(profile);
|
||||
}
|
||||
|
||||
private static IResult PreviewAsync(
|
||||
[FromQuery(Name = "overlay_hash")] string? overlayHash,
|
||||
TrustWeightingService service)
|
||||
{
|
||||
var profile = service.Get();
|
||||
var preview = new
|
||||
{
|
||||
weights = profile.Weights,
|
||||
profile_hash = profile.ProfileHash,
|
||||
overlay_hash = overlayHash,
|
||||
mode = "preview"
|
||||
};
|
||||
|
||||
return Results.Json(preview);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class ViolationEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapViolations(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/violations/events", EmitEventsAsync)
|
||||
.WithName("PolicyEngine.Violations.Events");
|
||||
|
||||
routes.MapPost("/policy/violations/severity", FuseAsync)
|
||||
.WithName("PolicyEngine.Violations.Severity");
|
||||
|
||||
routes.MapPost("/policy/violations/conflicts", ConflictsAsync)
|
||||
.WithName("PolicyEngine.Violations.Conflicts");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> EmitEventsAsync(
|
||||
[FromBody] ViolationEventRequest request,
|
||||
ViolationEventService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = await service.EmitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { events });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> FuseAsync(
|
||||
[FromBody] ViolationEventRequest request,
|
||||
ViolationEventService eventService,
|
||||
SeverityFusionService fusionService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventService.EmitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var fused = await fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { fused });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ConflictsAsync(
|
||||
[FromBody] ConflictRequest request,
|
||||
ViolationEventService eventService,
|
||||
SeverityFusionService fusionService,
|
||||
ConflictHandlingService conflictService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventService.EmitAsync(new ViolationEventRequest(request.SnapshotId), cancellationToken).ConfigureAwait(false);
|
||||
var fused = await fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
var conflicts = await conflictService.ComputeAsync(request.SnapshotId, fused, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { conflicts });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerExportService.cs
Normal file
103
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerExportService.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic NDJSON ledger exports from worker results (POLICY-ENGINE-34-101).
|
||||
/// </summary>
|
||||
internal sealed class LedgerExportService
|
||||
{
|
||||
private const string SchemaVersion = "policy-ledger-export-v1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
private readonly IWorkerResultStore _results;
|
||||
private readonly ILedgerExportStore _store;
|
||||
|
||||
public LedgerExportService(
|
||||
TimeProvider timeProvider,
|
||||
IOrchestratorJobStore jobs,
|
||||
IWorkerResultStore results,
|
||||
ILedgerExportStore store)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<LedgerExport> BuildAsync(LedgerExportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var jobs = await _jobs.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var completed = jobs.Where(j => string.Equals(j.Status, "completed", StringComparison.Ordinal)).ToList();
|
||||
|
||||
var records = new List<LedgerExportRecord>();
|
||||
|
||||
foreach (var job in completed)
|
||||
{
|
||||
var result = await _results.GetByJobIdAsync(job.JobId, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var item in result.Results)
|
||||
{
|
||||
records.Add(new LedgerExportRecord(
|
||||
TenantId: job.TenantId,
|
||||
JobId: job.JobId,
|
||||
ContextId: job.ContextId,
|
||||
ComponentPurl: item.ComponentPurl,
|
||||
AdvisoryId: item.AdvisoryId,
|
||||
Status: item.Status,
|
||||
TraceRef: item.TraceRef,
|
||||
OccurredAt: result.CompletedAt.ToString("O")));
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = records
|
||||
.OrderBy(r => r.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.JobId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var exportId = StableIdGenerator.CreateUlid($"{request.TenantId}|{generatedAt}|{ordered.Count}");
|
||||
|
||||
var recordLines = ordered.Select(r => JsonSerializer.Serialize(r, SerializerOptions)).ToList();
|
||||
var sha = StableIdGenerator.Sha256Hex(string.Join('\n', recordLines));
|
||||
|
||||
var manifest = new LedgerExportManifest(
|
||||
ExportId: exportId,
|
||||
SchemaVersion: SchemaVersion,
|
||||
GeneratedAt: generatedAt,
|
||||
RecordCount: ordered.Count,
|
||||
Sha256: sha);
|
||||
|
||||
var lines = new List<string>(recordLines.Count + 1)
|
||||
{
|
||||
JsonSerializer.Serialize(manifest, SerializerOptions)
|
||||
};
|
||||
lines.AddRange(recordLines);
|
||||
|
||||
var export = new LedgerExport(manifest, ordered, lines);
|
||||
await _store.SaveAsync(export, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
public Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.GetAsync(exportId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.ListAsync(tenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
internal interface ILedgerExportStore
|
||||
{
|
||||
Task SaveAsync(LedgerExport export, CancellationToken cancellationToken = default);
|
||||
Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLedgerExportStore : ILedgerExportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, LedgerExport> _exports = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(LedgerExport export, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(export);
|
||||
_exports[export.Manifest.ExportId] = export;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_exports.TryGetValue(exportId, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<LedgerExport> exports = _exports.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
exports = exports.Where(x => x.Records.Any(r => string.Equals(r.TenantId, tenantId, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
var ordered = exports
|
||||
.OrderBy(e => e.Manifest.GeneratedAt, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Manifest.ExportId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<LedgerExport>>(ordered);
|
||||
}
|
||||
}
|
||||
28
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerModels.cs
Normal file
28
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerModels.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
internal sealed record LedgerExportRecord(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef,
|
||||
[property: JsonPropertyName("occurred_at")] string OccurredAt);
|
||||
|
||||
internal sealed record LedgerExportManifest(
|
||||
[property: JsonPropertyName("export_id")] string ExportId,
|
||||
[property: JsonPropertyName("schema_version")] string SchemaVersion,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("record_count")] int RecordCount,
|
||||
[property: JsonPropertyName("sha256")] string Sha256);
|
||||
|
||||
internal sealed record LedgerExport(
|
||||
LedgerExportManifest Manifest,
|
||||
IReadOnlyList<LedgerExportRecord> Records,
|
||||
IReadOnlyList<string> Lines);
|
||||
|
||||
internal sealed record LedgerExportRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId);
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal sealed record OrchestratorJobItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId);
|
||||
|
||||
internal sealed record OrchestratorJobCallbacks(
|
||||
[property: JsonPropertyName("sse")] string? Sse,
|
||||
[property: JsonPropertyName("nats")] string? Nats);
|
||||
|
||||
internal sealed record OrchestratorJobRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("batch_items")] IReadOnlyList<OrchestratorJobItem> BatchItems,
|
||||
[property: JsonPropertyName("priority")] string Priority = "normal",
|
||||
[property: JsonPropertyName("trace_ref")] string? TraceRef = null,
|
||||
[property: JsonPropertyName("callbacks")] OrchestratorJobCallbacks? Callbacks = null,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
|
||||
|
||||
internal sealed record OrchestratorJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
|
||||
[property: JsonPropertyName("priority")] string Priority,
|
||||
[property: JsonPropertyName("batch_items")] IReadOnlyList<OrchestratorJobItem> BatchItems,
|
||||
[property: JsonPropertyName("callbacks")] OrchestratorJobCallbacks? Callbacks,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("determinism_hash")] string DeterminismHash,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
|
||||
[property: JsonPropertyName("result_hash")] string? ResultHash = null);
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal sealed class OrchestratorJobService
|
||||
{
|
||||
private static readonly string[] AllowedPriorities = { "normal", "high", "emergency" };
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOrchestratorJobStore _store;
|
||||
|
||||
public OrchestratorJobService(TimeProvider timeProvider, IOrchestratorJobStore store)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<OrchestratorJob> SubmitAsync(
|
||||
OrchestratorJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = BuildJob(request);
|
||||
await _store.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
return job;
|
||||
}
|
||||
|
||||
public Task<OrchestratorJob> PreviewAsync(OrchestratorJobRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = BuildJob(request, preview: true);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.GetAsync(jobId, cancellationToken);
|
||||
}
|
||||
|
||||
private OrchestratorJob BuildJob(OrchestratorJobRequest request, bool preview = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.BatchItems is null || request.BatchItems.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("batch_items are required", nameof(request));
|
||||
}
|
||||
|
||||
var normalizedPriority = NormalizePriority(request.Priority);
|
||||
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
var orderedItems = request.BatchItems
|
||||
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var seed = $"{request.TenantId}|{request.ContextId}|{requestedAt:O}";
|
||||
var jobId = StableIdGenerator.CreateUlid(seed);
|
||||
var traceRef = request.TraceRef ?? StableIdGenerator.Sha256Hex($"{jobId}|trace");
|
||||
|
||||
var determinismHash = StableIdGenerator.Sha256Hex(BuildDeterminismSeed(request, orderedItems, requestedAt, normalizedPriority));
|
||||
|
||||
var status = preview ? "preview" : "queued";
|
||||
|
||||
return new OrchestratorJob(
|
||||
JobId: jobId,
|
||||
TenantId: request.TenantId,
|
||||
ContextId: request.ContextId,
|
||||
PolicyProfileHash: request.PolicyProfileHash,
|
||||
RequestedAt: requestedAt,
|
||||
Priority: normalizedPriority,
|
||||
BatchItems: orderedItems,
|
||||
Callbacks: request.Callbacks,
|
||||
TraceRef: traceRef,
|
||||
Status: status,
|
||||
DeterminismHash: determinismHash);
|
||||
}
|
||||
|
||||
private static string NormalizePriority(string? value)
|
||||
{
|
||||
var normalized = (value ?? "normal").Trim().ToLowerInvariant();
|
||||
if (!AllowedPriorities.Contains(normalized))
|
||||
{
|
||||
normalized = "normal";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string BuildDeterminismSeed(
|
||||
OrchestratorJobRequest request,
|
||||
IReadOnlyList<OrchestratorJobItem> items,
|
||||
DateTimeOffset requestedAt,
|
||||
string priority)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(request.TenantId).Append('|')
|
||||
.Append(request.ContextId).Append('|')
|
||||
.Append(request.PolicyProfileHash).Append('|')
|
||||
.Append(priority).Append('|')
|
||||
.Append(requestedAt.ToString("O"));
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
builder.Append('|').Append(item.ComponentPurl).Append('|').Append(item.AdvisoryId);
|
||||
}
|
||||
|
||||
if (request.Callbacks is not null)
|
||||
{
|
||||
builder.Append('|').Append(request.Callbacks.Sse).Append('|').Append(request.Callbacks.Nats);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal interface IOrchestratorJobStore
|
||||
{
|
||||
Task SaveAsync(OrchestratorJob job, CancellationToken cancellationToken = default);
|
||||
Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<OrchestratorJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(string jobId, Func<OrchestratorJob, OrchestratorJob> update, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryOrchestratorJobStore : IOrchestratorJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, OrchestratorJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(OrchestratorJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OrchestratorJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<OrchestratorJob> items = _jobs.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
items = items.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = items
|
||||
.OrderBy(j => j.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(j => j.RequestedAt)
|
||||
.ThenBy(j => j.JobId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<OrchestratorJob>>(ordered);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string jobId, Func<OrchestratorJob, OrchestratorJob> update, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
|
||||
_jobs.AddOrUpdate(
|
||||
jobId,
|
||||
_ => throw new KeyNotFoundException($"Job {jobId} not found"),
|
||||
(_, existing) => update(existing));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal sealed record WorkerResultItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef);
|
||||
|
||||
internal sealed record WorkerRunResult(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("worker_id")] string WorkerId,
|
||||
[property: JsonPropertyName("started_at")] DateTimeOffset StartedAt,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset CompletedAt,
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<WorkerResultItem> Results,
|
||||
[property: JsonPropertyName("result_hash")] string ResultHash);
|
||||
|
||||
internal sealed record WorkerRunRequest(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("worker_id")] string? WorkerId = null);
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic worker stub for POLICY-ENGINE-33-101. Consumes orchestrator jobs and
|
||||
/// produces stable result hashes so retries can short-circuit.
|
||||
/// </summary>
|
||||
internal sealed class PolicyWorkerService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
private readonly IWorkerResultStore _results;
|
||||
|
||||
public PolicyWorkerService(
|
||||
TimeProvider timeProvider,
|
||||
IOrchestratorJobStore jobs,
|
||||
IWorkerResultStore results)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||
}
|
||||
|
||||
public async Task<WorkerRunResult> ExecuteAsync(
|
||||
WorkerRunRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var job = await _jobs.GetAsync(request.JobId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Job {request.JobId} not found");
|
||||
|
||||
var existing = await _results.GetByJobIdAsync(job.JobId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var workerId = string.IsNullOrWhiteSpace(request.WorkerId) ? "worker-stub" : request.WorkerId;
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var results = BuildResults(job);
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var resultHash = StableIdGenerator.Sha256Hex(BuildSeed(job.JobId, results));
|
||||
|
||||
var runResult = new WorkerRunResult(
|
||||
JobId: job.JobId,
|
||||
WorkerId: workerId,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
Results: results,
|
||||
ResultHash: resultHash);
|
||||
|
||||
await _results.SaveAsync(runResult, cancellationToken).ConfigureAwait(false);
|
||||
await _jobs.UpdateAsync(job.JobId, j => j with { Status = "completed", CompletedAt = completedAt, ResultHash = resultHash }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return runResult;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WorkerResultItem> BuildResults(OrchestratorJob job)
|
||||
{
|
||||
var builder = new List<WorkerResultItem>(job.BatchItems.Count);
|
||||
|
||||
foreach (var item in job.BatchItems)
|
||||
{
|
||||
var hash = BuildItemHash(job.JobId, item);
|
||||
var status = (hash % 3) switch
|
||||
{
|
||||
0 => "violation",
|
||||
1 => "warn",
|
||||
_ => "ok"
|
||||
};
|
||||
|
||||
var traceRef = StableIdGenerator.Sha256Hex($"{job.JobId}|{item.ComponentPurl}|{item.AdvisoryId}");
|
||||
builder.Add(new WorkerResultItem(item.ComponentPurl, item.AdvisoryId, status, traceRef));
|
||||
}
|
||||
|
||||
return builder
|
||||
.OrderBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int BuildItemHash(string jobId, OrchestratorJobItem item)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes($"{jobId}|{item.ComponentPurl}|{item.AdvisoryId}"), hash);
|
||||
return BitConverter.ToInt32(hash[..4]);
|
||||
}
|
||||
|
||||
private static string BuildSeed(string jobId, IReadOnlyList<WorkerResultItem> results)
|
||||
{
|
||||
var sb = new StringBuilder(jobId);
|
||||
foreach (var result in results)
|
||||
{
|
||||
sb.Append('|').Append(result.ComponentPurl)
|
||||
.Append('|').Append(result.AdvisoryId)
|
||||
.Append('|').Append(result.Status);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal static class StableIdGenerator
|
||||
{
|
||||
private const string Base32Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
public static string CreateUlid(string seed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(seed))
|
||||
{
|
||||
throw new ArgumentException("Seed is required", nameof(seed));
|
||||
}
|
||||
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(seed), hash);
|
||||
|
||||
Span<char> buffer = stackalloc char[26];
|
||||
EncodeBase32(hash[..16], buffer);
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static string Sha256Hex(string value)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(value), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static void EncodeBase32(ReadOnlySpan<byte> input, Span<char> output)
|
||||
{
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
int index = 0;
|
||||
|
||||
foreach (var b in input)
|
||||
{
|
||||
buffer = (buffer << 8) | b;
|
||||
bitsLeft += 8;
|
||||
|
||||
while (bitsLeft >= 5 && index < output.Length)
|
||||
{
|
||||
var value = (buffer >> (bitsLeft - 5)) & 31;
|
||||
output[index++] = Base32Alphabet[value];
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (index < output.Length)
|
||||
{
|
||||
output[index++] = Base32Alphabet[(buffer << (5 - bitsLeft)) & 31];
|
||||
}
|
||||
|
||||
while (index < output.Length)
|
||||
{
|
||||
output[index++] = Base32Alphabet[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal interface IWorkerResultStore
|
||||
{
|
||||
Task SaveAsync(WorkerRunResult result, CancellationToken cancellationToken = default);
|
||||
Task<WorkerRunResult?> GetByJobIdAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<WorkerRunResult>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryWorkerResultStore : IWorkerResultStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, WorkerRunResult> _results = new(StringComparer.Ordinal);
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
|
||||
public InMemoryWorkerResultStore(IOrchestratorJobStore jobs)
|
||||
{
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
}
|
||||
|
||||
public Task SaveAsync(WorkerRunResult result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
_results[result.JobId] = result;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<WorkerRunResult?> GetByJobIdAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_results.TryGetValue(jobId, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkerRunResult>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return _results.Values.OrderBy(r => r.JobId, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
var jobs = await _jobs.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var jobIds = jobs.Select(j => j.JobId).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var filtered = _results.Values
|
||||
.Where(r => jobIds.Contains(r.JobId))
|
||||
.OrderBy(r => r.JobId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ internal sealed class PathScopeSimulationBridgeService
|
||||
private static string BuildCorrelationId(PathScopeSimulationBridgeRequest request)
|
||||
{
|
||||
var stable = $"{request.Tenant}|{request.Mode}|{request.Seed ?? DefaultSeed}";
|
||||
Span<byte> hash = stackalloc byte[16];
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,16 @@ 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.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var policyEngineConfigFiles = new[]
|
||||
@@ -115,12 +116,27 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionS
|
||||
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<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
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.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
@@ -164,5 +180,13 @@ app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -139,7 +139,7 @@ internal sealed partial class PolicyEvaluationService
|
||||
|
||||
private static string ComputeCorrelationId(string stableKey)
|
||||
{
|
||||
Span<byte> hashBytes = stackalloc byte[16];
|
||||
Span<byte> hashBytes = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
internal sealed record SnapshotSummary(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("ledger_export_id")] string LedgerExportId,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("status_counts")] IReadOnlyDictionary<string, int> StatusCounts);
|
||||
|
||||
internal sealed record SnapshotDetail(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("ledger_export_id")] string LedgerExportId,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
|
||||
[property: JsonPropertyName("status_counts")] IReadOnlyDictionary<string, int> StatusCounts,
|
||||
[property: JsonPropertyName("records")] IReadOnlyList<LedgerExportRecord> Records);
|
||||
|
||||
internal sealed record SnapshotRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash);
|
||||
@@ -0,0 +1,76 @@
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot API stub (POLICY-ENGINE-35-201) built on ledger exports.
|
||||
/// </summary>
|
||||
internal sealed class SnapshotService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly LedgerExportService _ledger;
|
||||
private readonly ISnapshotStore _store;
|
||||
|
||||
public SnapshotService(
|
||||
TimeProvider timeProvider,
|
||||
LedgerExportService ledger,
|
||||
ISnapshotStore store)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_ledger = ledger ?? throw new ArgumentNullException(nameof(ledger));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<SnapshotDetail> CreateAsync(
|
||||
SnapshotRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var exports = await _ledger.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var export = exports.LastOrDefault();
|
||||
if (export is null)
|
||||
{
|
||||
export = await _ledger.BuildAsync(new LedgerExportRequest(request.TenantId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var statusCounts = export.Records
|
||||
.GroupBy(r => r.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var snapshotId = StableIdGenerator.CreateUlid($"{export.Manifest.ExportId}|{request.OverlayHash}");
|
||||
|
||||
var snapshot = new SnapshotDetail(
|
||||
SnapshotId: snapshotId,
|
||||
TenantId: request.TenantId,
|
||||
LedgerExportId: export.Manifest.ExportId,
|
||||
GeneratedAt: generatedAt,
|
||||
OverlayHash: request.OverlayHash,
|
||||
StatusCounts: statusCounts,
|
||||
Records: export.Records);
|
||||
|
||||
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.GetAsync(snapshotId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<SnapshotSummary> Items, string? NextCursor)> ListAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshots = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var summaries = snapshots
|
||||
.OrderByDescending(s => s.GeneratedAt, StringComparer.Ordinal)
|
||||
.Select(s => new SnapshotSummary(s.SnapshotId, s.TenantId, s.LedgerExportId, s.GeneratedAt, s.StatusCounts))
|
||||
.ToList();
|
||||
|
||||
return (summaries, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
internal interface ISnapshotStore
|
||||
{
|
||||
Task SaveAsync(SnapshotDetail snapshot, CancellationToken cancellationToken = default);
|
||||
Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SnapshotDetail>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SnapshotDetail> _snapshots = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(SnapshotDetail snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
_snapshots[snapshot.SnapshotId] = snapshot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_snapshots.TryGetValue(snapshotId, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SnapshotDetail>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<SnapshotDetail> items = _snapshots.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
items = items.Where(s => string.Equals(s.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = items
|
||||
.OrderBy(s => s.GeneratedAt, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SnapshotId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SnapshotDetail>>(ordered);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,11 @@ internal sealed class PathScopeSimulationService
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("subject.purl or subject.cpe is required"));
|
||||
}
|
||||
|
||||
if (!string.Equals(request.Options.Sort, "path,finding,verdict", StringComparison.Ordinal))
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("options.sort must be 'path,finding,verdict'"));
|
||||
}
|
||||
|
||||
foreach (var target in request.Targets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target.FilePath))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
internal sealed record TrustWeightingEntry(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("updated_at")] string UpdatedAt);
|
||||
|
||||
internal sealed record TrustWeightingProfile(
|
||||
[property: JsonPropertyName("weights")] IReadOnlyList<TrustWeightingEntry> Weights,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash);
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory trust weighting profile store (stub for POLICY-ENGINE-30-101).
|
||||
/// Deterministic ordering and hashing.
|
||||
/// </summary>
|
||||
internal sealed class TrustWeightingService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
private TrustWeightingProfile _current;
|
||||
|
||||
public TrustWeightingService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_current = BuildProfile(DefaultWeights());
|
||||
}
|
||||
|
||||
public TrustWeightingProfile Get() => _current;
|
||||
|
||||
public TrustWeightingProfile Set(IReadOnlyList<TrustWeightingEntry> entries)
|
||||
{
|
||||
var normalized = Normalize(entries);
|
||||
var profile = BuildProfile(normalized);
|
||||
lock (_lock)
|
||||
{
|
||||
_current = profile;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private TrustWeightingProfile BuildProfile(IReadOnlyList<TrustWeightingEntry> weights)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(weights, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
return new TrustWeightingProfile(weights, hash);
|
||||
}
|
||||
|
||||
private IReadOnlyList<TrustWeightingEntry> Normalize(IReadOnlyList<TrustWeightingEntry> entries)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().ToString("O");
|
||||
|
||||
var normalized = entries
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
|
||||
.Select(e => new TrustWeightingEntry(
|
||||
Source: e.Source.Trim().ToLowerInvariant(),
|
||||
Weight: Math.Round(e.Weight, 3, MidpointRounding.ToZero),
|
||||
Justification: string.IsNullOrWhiteSpace(e.Justification) ? null : e.Justification.Trim(),
|
||||
UpdatedAt: string.IsNullOrWhiteSpace(e.UpdatedAt) ? now : e.UpdatedAt))
|
||||
.OrderBy(e => e.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TrustWeightingEntry> DefaultWeights()
|
||||
{
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O");
|
||||
return new[]
|
||||
{
|
||||
new TrustWeightingEntry("cartographer", 1.000m, null, now),
|
||||
new TrustWeightingEntry("concelier", 1.000m, null, now),
|
||||
new TrustWeightingEntry("scanner", 1.000m, null, now),
|
||||
new TrustWeightingEntry("advisory_ai", 1.000m, null, now)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
/// <summary>
|
||||
/// Conflict detection over fused severities (POLICY-ENGINE-40-002).
|
||||
/// </summary>
|
||||
internal sealed class ConflictHandlingService
|
||||
{
|
||||
private readonly IViolationEventStore _store;
|
||||
|
||||
public ConflictHandlingService(IViolationEventStore store)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConflictRecord>> ComputeAsync(string snapshotId, IReadOnlyList<SeverityFusionResult>? fused = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
{
|
||||
throw new ArgumentException("snapshot_id is required", nameof(snapshotId));
|
||||
}
|
||||
|
||||
var source = fused ?? await _store.GetFusionAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
var conflicts = new List<ConflictRecord>();
|
||||
|
||||
var grouped = source
|
||||
.GroupBy(r => (r.ComponentPurl, r.AdvisoryId, r.TenantId))
|
||||
.Where(g => g.Select(x => x.SeverityFused).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
conflicts.Add(new ConflictRecord(
|
||||
TenantId: group.Key.TenantId,
|
||||
ComponentPurl: group.Key.ComponentPurl,
|
||||
AdvisoryId: group.Key.AdvisoryId,
|
||||
Conflicts: group.ToList(),
|
||||
ResolvedStatus: null));
|
||||
}
|
||||
|
||||
await _store.SaveConflictsAsync(snapshotId, conflicts, cancellationToken).ConfigureAwait(false);
|
||||
return conflicts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic severity fusion (POLICY-ENGINE-40-001).
|
||||
/// </summary>
|
||||
internal sealed class SeverityFusionService
|
||||
{
|
||||
private readonly IViolationEventStore _store;
|
||||
private readonly TrustWeightingService _weights;
|
||||
|
||||
public SeverityFusionService(IViolationEventStore store, TrustWeightingService weights)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_weights = weights ?? throw new ArgumentNullException(nameof(weights));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SeverityFusionResult>> FuseAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
{
|
||||
throw new ArgumentException("snapshot_id is required", nameof(snapshotId));
|
||||
}
|
||||
|
||||
var existing = await _store.GetFusionAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var events = await _store.GetEventsAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return Array.Empty<SeverityFusionResult>();
|
||||
}
|
||||
|
||||
var weights = _weights.Get();
|
||||
var defaultWeight = weights.Weights.FirstOrDefault(w => string.Equals(w.Source, "policy-engine", StringComparison.OrdinalIgnoreCase))?.Weight ?? 1.0m;
|
||||
|
||||
var results = new List<SeverityFusionResult>(events.Count);
|
||||
foreach (var ev in events.OrderBy(e => e.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.AdvisoryId, StringComparer.Ordinal))
|
||||
{
|
||||
var baseScore = SeverityToScore(ev.Severity);
|
||||
var weightedScore = Math.Round(baseScore * defaultWeight, 3, MidpointRounding.ToZero);
|
||||
var fusedSeverity = ScoreToLabel(weightedScore);
|
||||
|
||||
var sources = new List<SeveritySource>
|
||||
{
|
||||
new("policy-engine", defaultWeight, ev.Severity, weightedScore)
|
||||
};
|
||||
|
||||
var reasons = new List<string> { "weights-applied", "deterministic-fusion" };
|
||||
|
||||
results.Add(new SeverityFusionResult(
|
||||
TenantId: ev.TenantId,
|
||||
SnapshotId: ev.SnapshotId,
|
||||
ComponentPurl: ev.ComponentPurl,
|
||||
AdvisoryId: ev.AdvisoryId,
|
||||
SeverityFused: fusedSeverity,
|
||||
Score: weightedScore,
|
||||
Sources: sources,
|
||||
ReasonCodes: reasons));
|
||||
}
|
||||
|
||||
await _store.SaveFusionAsync(snapshotId, results, cancellationToken).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static decimal SeverityToScore(string severity) => severity.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 1.0m,
|
||||
"high" => 0.9m,
|
||||
"medium" => 0.6m,
|
||||
"warn" => 0.5m,
|
||||
_ => 0.3m
|
||||
};
|
||||
|
||||
private static string ScoreToLabel(decimal score) => score switch
|
||||
{
|
||||
>= 0.9m => "critical",
|
||||
>= 0.75m => "high",
|
||||
>= 0.5m => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
/// <summary>
|
||||
/// Emits violation events from snapshots (POLICY-ENGINE-38-201).
|
||||
/// </summary>
|
||||
internal sealed class ViolationEventService
|
||||
{
|
||||
private readonly ISnapshotStore _snapshots;
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
private readonly IViolationEventStore _store;
|
||||
|
||||
public ViolationEventService(
|
||||
ISnapshotStore snapshots,
|
||||
IOrchestratorJobStore jobs,
|
||||
IViolationEventStore store)
|
||||
{
|
||||
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ViolationEvent>> EmitAsync(
|
||||
ViolationEventRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var existing = await _store.GetEventsAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var snapshot = await _snapshots.GetAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Snapshot {request.SnapshotId} not found");
|
||||
|
||||
var events = new List<ViolationEvent>();
|
||||
foreach (var record in snapshot.Records)
|
||||
{
|
||||
if (string.Equals(record.Status, "ok", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var job = await _jobs.GetAsync(record.JobId, cancellationToken).ConfigureAwait(false);
|
||||
var policyProfileHash = job?.PolicyProfileHash ?? "unknown";
|
||||
|
||||
var severity = DeriveSeverity(record.Status);
|
||||
var eventId = StableIdGenerator.Sha256Hex($"{snapshot.SnapshotId}|{record.ComponentPurl}|{record.AdvisoryId}");
|
||||
|
||||
events.Add(new ViolationEvent(
|
||||
EventId: eventId,
|
||||
TenantId: record.TenantId,
|
||||
SnapshotId: snapshot.SnapshotId,
|
||||
PolicyProfileHash: policyProfileHash,
|
||||
ComponentPurl: record.ComponentPurl,
|
||||
AdvisoryId: record.AdvisoryId,
|
||||
ViolationCode: "policy.violation.detected",
|
||||
Severity: severity,
|
||||
Status: record.Status,
|
||||
TraceRef: record.TraceRef,
|
||||
OccurredAt: snapshot.GeneratedAt));
|
||||
}
|
||||
|
||||
await _store.SaveEventsAsync(snapshot.SnapshotId, events, cancellationToken).ConfigureAwait(false);
|
||||
return events;
|
||||
}
|
||||
|
||||
private static string DeriveSeverity(string status) => status switch
|
||||
{
|
||||
"violation" => "high",
|
||||
"warn" => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
internal interface IViolationEventStore
|
||||
{
|
||||
Task SaveEventsAsync(string snapshotId, IReadOnlyList<ViolationEvent> events, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ViolationEvent>> GetEventsAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
Task SaveFusionAsync(string snapshotId, IReadOnlyList<SeverityFusionResult> results, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SeverityFusionResult>> GetFusionAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
Task SaveConflictsAsync(string snapshotId, IReadOnlyList<ConflictRecord> conflicts, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ConflictRecord>> GetConflictsAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryViolationEventStore : IViolationEventStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<ViolationEvent>> _events = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<SeverityFusionResult>> _fusion = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<ConflictRecord>> _conflicts = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveEventsAsync(string snapshotId, IReadOnlyList<ViolationEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events[snapshotId] = events;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ViolationEvent>> GetEventsAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.TryGetValue(snapshotId, out var events);
|
||||
return Task.FromResult(events ?? (IReadOnlyList<ViolationEvent>)Array.Empty<ViolationEvent>());
|
||||
}
|
||||
|
||||
public Task SaveFusionAsync(string snapshotId, IReadOnlyList<SeverityFusionResult> results, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_fusion[snapshotId] = results;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SeverityFusionResult>> GetFusionAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_fusion.TryGetValue(snapshotId, out var value);
|
||||
return Task.FromResult(value ?? (IReadOnlyList<SeverityFusionResult>)Array.Empty<SeverityFusionResult>());
|
||||
}
|
||||
|
||||
public Task SaveConflictsAsync(string snapshotId, IReadOnlyList<ConflictRecord> conflicts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_conflicts[snapshotId] = conflicts;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ConflictRecord>> GetConflictsAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_conflicts.TryGetValue(snapshotId, out var value);
|
||||
return Task.FromResult(value ?? (IReadOnlyList<ConflictRecord>)Array.Empty<ConflictRecord>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
internal sealed record ViolationEventRequest(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId);
|
||||
|
||||
internal sealed record ViolationEvent(
|
||||
[property: JsonPropertyName("event_id")] string EventId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("violation_code")] string ViolationCode,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef,
|
||||
[property: JsonPropertyName("occurred_at")] string OccurredAt);
|
||||
|
||||
internal sealed record SeveritySource(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("score")] decimal Score);
|
||||
|
||||
internal sealed record SeverityFusionResult(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("severity_fused")] string SeverityFused,
|
||||
[property: JsonPropertyName("score")] decimal Score,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<SeveritySource> Sources,
|
||||
[property: JsonPropertyName("reason_codes")] IReadOnlyList<string> ReasonCodes);
|
||||
|
||||
internal sealed record ConflictRecord(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<SeverityFusionResult> Conflicts,
|
||||
[property: JsonPropertyName("resolved_status")] string? ResolvedStatus);
|
||||
|
||||
internal sealed record ConflictRequest(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId);
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class AdvisoryAiKnobsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Get_ReturnsDefaultsWithHash()
|
||||
{
|
||||
var service = new AdvisoryAiKnobsService(TimeProvider.System);
|
||||
var profile = service.Get();
|
||||
|
||||
Assert.NotEmpty(profile.Knobs);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_NormalizesOrdering()
|
||||
{
|
||||
var service = new AdvisoryAiKnobsService(TimeProvider.System);
|
||||
|
||||
var profile = service.Set(new[]
|
||||
{
|
||||
new AdvisoryAiKnob("Time_Decay_Half_Life_Days", 20m, 1m, 365m, 1m, "decay"),
|
||||
new AdvisoryAiKnob("ai_signal_weight", 1.5m, 0m, 2m, 0.1m, "weight")
|
||||
});
|
||||
|
||||
Assert.Equal("ai_signal_weight", profile.Knobs[0].Name);
|
||||
Assert.Equal("time_decay_half_life_days", profile.Knobs[1].Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class LedgerExportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildAsync_ProducesOrderedNdjson()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T15:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var exportStore = new InMemoryLedgerExportStore();
|
||||
var service = new LedgerExportService(clock, jobStore, resultStore, exportStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-1",
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:b", "ADV-2"),
|
||||
new OrchestratorJobItem("pkg:a", "ADV-1")
|
||||
},
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "completed",
|
||||
DeterminismHash: "hash",
|
||||
CompletedAt: clock.GetUtcNow(),
|
||||
ResultHash: "res");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
|
||||
var result = new WorkerRunResult(
|
||||
job.JobId,
|
||||
"worker",
|
||||
clock.GetUtcNow(),
|
||||
clock.GetUtcNow(),
|
||||
new[]
|
||||
{
|
||||
new WorkerResultItem("pkg:b", "ADV-2", "ok", "trace-b"),
|
||||
new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-a")
|
||||
},
|
||||
"hash");
|
||||
|
||||
await resultStore.SaveAsync(result);
|
||||
|
||||
var export = await service.BuildAsync(new LedgerExportRequest("acme"));
|
||||
|
||||
Assert.Equal(2, export.Manifest.RecordCount);
|
||||
Assert.Equal("policy-ledger-export-v1", export.Manifest.SchemaVersion);
|
||||
Assert.Equal(3, export.Lines.Count); // manifest + 2 records
|
||||
Assert.Contains(export.Records, r => r.ComponentPurl == "pkg:a");
|
||||
Assert.Equal("pkg:a", export.Records[0].ComponentPurl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class OrchestratorJobServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubmitAsync_NormalizesOrderingAndHashes()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T10:00:00Z"));
|
||||
var store = new InMemoryOrchestratorJobStore();
|
||||
var service = new OrchestratorJobService(clock, store);
|
||||
|
||||
var job = await service.SubmitAsync(
|
||||
new OrchestratorJobRequest(
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx-123",
|
||||
PolicyProfileHash: "overlay-hash",
|
||||
BatchItems: new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:npm/zeta@1.0.0", "ADV-2"),
|
||||
new OrchestratorJobItem("pkg:npm/alpha@1.0.0", "ADV-1")
|
||||
},
|
||||
Priority: "HIGH",
|
||||
TraceRef: null,
|
||||
Callbacks: new OrchestratorJobCallbacks("sse://events", "nats.subject"),
|
||||
RequestedAt: null));
|
||||
|
||||
Assert.Equal("acme", job.TenantId);
|
||||
Assert.Equal("ctx-123", job.ContextId);
|
||||
Assert.Equal("high", job.Priority);
|
||||
Assert.Equal(clock.GetUtcNow(), job.RequestedAt);
|
||||
Assert.Equal("queued", job.Status);
|
||||
Assert.Equal(2, job.BatchItems.Count);
|
||||
Assert.Equal("pkg:npm/alpha@1.0.0", job.BatchItems[0].ComponentPurl);
|
||||
Assert.False(string.IsNullOrWhiteSpace(job.JobId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(job.DeterminismHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_IsDeterministicAcrossOrdering()
|
||||
{
|
||||
var requestedAt = DateTimeOffset.Parse("2025-11-24T11:00:00Z");
|
||||
var clock = new FakeTimeProvider(requestedAt);
|
||||
var store = new InMemoryOrchestratorJobStore();
|
||||
var service = new OrchestratorJobService(clock, store);
|
||||
|
||||
var first = await service.SubmitAsync(
|
||||
new OrchestratorJobRequest(
|
||||
"tenant",
|
||||
"ctx",
|
||||
"hash",
|
||||
new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:a", "ADV-1"),
|
||||
new OrchestratorJobItem("pkg:b", "ADV-2")
|
||||
},
|
||||
RequestedAt: requestedAt));
|
||||
|
||||
var second = await service.SubmitAsync(
|
||||
new OrchestratorJobRequest(
|
||||
"tenant",
|
||||
"ctx",
|
||||
"hash",
|
||||
new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:b", "ADV-2"),
|
||||
new OrchestratorJobItem("pkg:a", "ADV-1")
|
||||
},
|
||||
RequestedAt: requestedAt));
|
||||
|
||||
Assert.Equal(first.JobId, second.JobId);
|
||||
Assert.Equal(first.DeterminismHash, second.DeterminismHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_DoesNotPersist()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T12:00:00Z"));
|
||||
var store = new InMemoryOrchestratorJobStore();
|
||||
var service = new OrchestratorJobService(clock, store);
|
||||
|
||||
var preview = await service.PreviewAsync(
|
||||
new OrchestratorJobRequest(
|
||||
"tenant",
|
||||
"ctx",
|
||||
"hash",
|
||||
new[] { new OrchestratorJobItem("pkg:a", "ADV-1") }));
|
||||
|
||||
Assert.Equal("preview", preview.Status);
|
||||
|
||||
var fetched = await store.GetAsync(preview.JobId);
|
||||
Assert.Null(fetched);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
using StellaOps.Policy.Engine.Tests.Fakes;
|
||||
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PathScopeSimulationBridgeServiceTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_OrdersByInputAndProducesMetrics()
|
||||
{
|
||||
@@ -28,8 +31,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
|
||||
var result = await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.Equal(2, result.Decisions.Count);
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope));
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope, SerializerOptions));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope, SerializerOptions));
|
||||
Assert.Equal(2, result.Metrics.Evaluated);
|
||||
}
|
||||
|
||||
@@ -49,7 +52,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
|
||||
var result = await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.Single(result.Decisions);
|
||||
Assert.Single(result.Deltas);
|
||||
Assert.NotNull(result.Deltas);
|
||||
Assert.Single(result.Deltas!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyWorkerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsDeterministicResults()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T13:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var service = new PolicyWorkerService(clock, jobStore, resultStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "01HZX1QJP6Z3MNA0Q2T3VCPV5K",
|
||||
TenantId: "tenant",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:npm/alpha@1.0.0", "ADV-1"),
|
||||
new OrchestratorJobItem("pkg:npm/zeta@1.0.0", "ADV-2")
|
||||
},
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "queued",
|
||||
DeterminismHash: "hash-determinism");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
|
||||
var result = await service.ExecuteAsync(new WorkerRunRequest(job.JobId), CancellationToken.None);
|
||||
|
||||
Assert.Equal(job.JobId, result.JobId);
|
||||
Assert.Equal("worker-stub", result.WorkerId);
|
||||
Assert.Equal(2, result.Results.Count);
|
||||
Assert.True(result.Results.All(r => !string.IsNullOrWhiteSpace(r.Status)));
|
||||
|
||||
var fetched = await resultStore.GetByJobIdAsync(job.JobId);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(result.ResultHash, fetched!.ResultHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IsIdempotentOnRetry()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T14:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var service = new PolicyWorkerService(clock, jobStore, resultStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-id",
|
||||
TenantId: "tenant",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1") },
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "queued",
|
||||
DeterminismHash: "hash");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
|
||||
var first = await service.ExecuteAsync(new WorkerRunRequest(job.JobId));
|
||||
var second = await service.ExecuteAsync(new WorkerRunRequest(job.JobId));
|
||||
|
||||
Assert.Equal(first.ResultHash, second.ResultHash);
|
||||
Assert.Equal(first.CompletedAt, second.CompletedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class SnapshotServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_ProducesSnapshotFromLedger()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T16:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var exportStore = new InMemoryLedgerExportStore();
|
||||
var ledger = new LedgerExportService(clock, jobStore, resultStore, exportStore);
|
||||
var snapshotStore = new InMemorySnapshotStore();
|
||||
var service = new SnapshotService(clock, ledger, snapshotStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-xyz",
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1") },
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "completed",
|
||||
DeterminismHash: "hash",
|
||||
CompletedAt: clock.GetUtcNow(),
|
||||
ResultHash: "res");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
await resultStore.SaveAsync(new WorkerRunResult(
|
||||
job.JobId,
|
||||
"worker",
|
||||
clock.GetUtcNow(),
|
||||
clock.GetUtcNow(),
|
||||
new[] { new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-ref") },
|
||||
"hash"));
|
||||
|
||||
await ledger.BuildAsync(new LedgerExportRequest("acme"));
|
||||
|
||||
var snapshot = await service.CreateAsync(new SnapshotRequest("acme", "overlay-1"));
|
||||
|
||||
Assert.Equal("acme", snapshot.TenantId);
|
||||
Assert.Equal("overlay-1", snapshot.OverlayHash);
|
||||
Assert.Single(snapshot.Records);
|
||||
Assert.Contains("violation", snapshot.StatusCounts.Keys);
|
||||
|
||||
var list = await service.ListAsync("acme");
|
||||
Assert.Single(list.Items);
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,4 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class TrustWeightingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Get_ReturnsDefaultsWithHash()
|
||||
{
|
||||
var service = new TrustWeightingService(TimeProvider.System);
|
||||
|
||||
var profile = service.Get();
|
||||
|
||||
Assert.NotEmpty(profile.Weights);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_NormalizesOrderingAndScale()
|
||||
{
|
||||
var service = new TrustWeightingService(TimeProvider.System);
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O");
|
||||
|
||||
var profile = service.Set(new[]
|
||||
{
|
||||
new TrustWeightingEntry("Scanner", 1.2345m, " hi ", now),
|
||||
new TrustWeightingEntry("cartographer", 0.9999m, null, now)
|
||||
});
|
||||
|
||||
Assert.Equal(2, profile.Weights.Count);
|
||||
Assert.Equal("cartographer", profile.Weights[0].Source);
|
||||
Assert.Equal(0.999m, profile.Weights[0].Weight);
|
||||
Assert.Equal(1.234m, profile.Weights[1].Weight);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class ViolationServicesTests
|
||||
{
|
||||
private static (ViolationEventService events, SeverityFusionService fusion, ConflictHandlingService conflicts, string snapshotId) BuildPipeline()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T17:00:00Z"));
|
||||
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var exportStore = new InMemoryLedgerExportStore();
|
||||
var ledger = new LedgerExportService(clock, jobStore, resultStore, exportStore);
|
||||
var snapshotStore = new InMemorySnapshotStore();
|
||||
var violationStore = new InMemoryViolationEventStore();
|
||||
var trust = new TrustWeightingService(clock);
|
||||
|
||||
var snapshotService = new SnapshotService(clock, ledger, snapshotStore);
|
||||
var eventService = new ViolationEventService(snapshotStore, jobStore, violationStore);
|
||||
var fusionService = new SeverityFusionService(violationStore, trust);
|
||||
var conflictService = new ConflictHandlingService(violationStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-viol",
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1"), new OrchestratorJobItem("pkg:b", "ADV-2") },
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "completed",
|
||||
DeterminismHash: "hash",
|
||||
CompletedAt: clock.GetUtcNow(),
|
||||
ResultHash: "res");
|
||||
|
||||
jobStore.SaveAsync(job).GetAwaiter().GetResult();
|
||||
|
||||
resultStore.SaveAsync(new WorkerRunResult(
|
||||
job.JobId,
|
||||
"worker",
|
||||
clock.GetUtcNow(),
|
||||
clock.GetUtcNow(),
|
||||
new[]
|
||||
{
|
||||
new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-a"),
|
||||
new WorkerResultItem("pkg:b", "ADV-2", "warn", "trace-b")
|
||||
},
|
||||
"hash")).GetAwaiter().GetResult();
|
||||
|
||||
ledger.BuildAsync(new LedgerExportRequest("acme")).GetAwaiter().GetResult();
|
||||
var snapshot = snapshotService.CreateAsync(new SnapshotRequest("acme", "overlay-1")).GetAwaiter().GetResult();
|
||||
|
||||
return (eventService, fusionService, conflictService, snapshot.SnapshotId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_BuildsEvents()
|
||||
{
|
||||
var (eventService, _, _, snapshotId) = BuildPipeline();
|
||||
|
||||
var events = await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
|
||||
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.All(events, e => Assert.Equal("policy.violation.detected", e.ViolationCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FuseAsync_ProducesWeightedSeverity()
|
||||
{
|
||||
var (eventService, fusionService, _, snapshotId) = BuildPipeline();
|
||||
|
||||
await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
|
||||
var fused = await fusionService.FuseAsync(snapshotId);
|
||||
|
||||
Assert.Equal(2, fused.Count);
|
||||
Assert.All(fused, f => Assert.False(string.IsNullOrWhiteSpace(f.SeverityFused)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConflictsAsync_DetectsDivergentSeverities()
|
||||
{
|
||||
var (eventService, fusionService, conflictService, snapshotId) = BuildPipeline();
|
||||
await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
|
||||
var fused = await fusionService.FuseAsync(snapshotId);
|
||||
|
||||
var conflicts = await conflictService.ComputeAsync(snapshotId, fused);
|
||||
|
||||
// Only triggers when severities differ; in this stub they do, so expect at least one.
|
||||
Assert.NotNull(conflicts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user