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

This commit is contained in:
StellaOps Bot
2025-11-24 07:52:25 +02:00
parent 5970f0d9bd
commit 150b3730ef
215 changed files with 8119 additions and 740 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]

View File

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

View File

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

View File

@@ -11,4 +11,4 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
</ItemGroup>
</Project>
</Project>

View File

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

View File

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