diff --git a/docs/implplan/SPRINT_0125_0001_0001_policy_reasoning.md b/docs/implplan/SPRINT_0125_0001_0001_policy_reasoning.md index a6939a066..a5d904bcf 100644 --- a/docs/implplan/SPRINT_0125_0001_0001_policy_reasoning.md +++ b/docs/implplan/SPRINT_0125_0001_0001_policy_reasoning.md @@ -34,9 +34,9 @@ | P14 | PREP-POLICY-ENGINE-40-002-DEPENDS-ON-40-001 | DONE (2025-11-22) | Due 2025-11-22 · Accountable: Policy · Excititor Guild / `src/Policy/StellaOps.Policy.Engine` | Policy · Excititor Guild / `src/Policy/StellaOps.Policy.Engine` | Depends on 40-001.

Document artefact/deliverable for POLICY-ENGINE-40-002 and publish location so downstream tasks can proceed. | | 1 | POLICY-ENGINE-29-003 | DONE (2025-11-23) | Path/scope streaming endpoint `/simulation/path-scope` implemented with deterministic evaluation stub (hash-based); contract aligned to 29-002 schema; tests added. | Policy · SBOM Service Guild / `src/Policy/StellaOps.Policy.Engine` | Path/scope aware evaluation. | | 2 | POLICY-ENGINE-29-004 | DONE (2025-11-23) | PREP-POLICY-ENGINE-29-004-DEPENDS-ON-29-003 | Policy · Observability Guild / `src/Policy/StellaOps.Policy.Engine` | Metrics/logging for path-aware eval. | -| 3 | POLICY-ENGINE-30-001 | TODO | PREP-POLICY-ENGINE-30-001-NEEDS-29-004-OUTPUT | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Overlay projection contract. | -| 4 | POLICY-ENGINE-30-002 | TODO | PREP-POLICY-ENGINE-30-002-DEPENDS-ON-30-001 | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Simulation bridge. | -| 5 | POLICY-ENGINE-30-003 | TODO | PREP-POLICY-ENGINE-30-003-DEPENDS-ON-30-002 | Policy · Scheduler Guild / `src/Policy/StellaOps.Policy.Engine` | Change events. | +| 3 | POLICY-ENGINE-30-001 | DONE (2025-11-23) | PREP-POLICY-ENGINE-30-001-NEEDS-29-004-OUTPUT | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Overlay projection contract. | +| 4 | POLICY-ENGINE-30-002 | DONE (2025-11-23) | PREP-POLICY-ENGINE-30-002-DEPENDS-ON-30-001 | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Simulation bridge. | +| 5 | POLICY-ENGINE-30-003 | DOING (2025-11-23) | PREP-POLICY-ENGINE-30-003-DEPENDS-ON-30-002 | Policy · Scheduler Guild / `src/Policy/StellaOps.Policy.Engine` | Change events. | | 6 | POLICY-ENGINE-30-101 | TODO | PREP-POLICY-ENGINE-30-101-DEPENDS-ON-30-003 | Policy Guild / `src/Policy/StellaOps.Policy.Engine` | Trust weighting UI/API. | | 7 | POLICY-ENGINE-31-001 | TODO | PREP-POLICY-ENGINE-31-001-DEPENDS-ON-30-101 | Policy Guild / `src/Policy/StellaOps.Policy.Engine` | Advisory AI knobs. | | 8 | POLICY-ENGINE-31-002 | TODO | PREP-POLICY-ENGINE-31-002-DEPENDS-ON-31-001 | Policy Guild / `src/Policy/StellaOps.Policy.Engine` | Batch context endpoint. | @@ -59,6 +59,9 @@ | 2025-11-23 | Started POLICY-ENGINE-29-003 implementation; added PathScopeSimulationService scaffold and unit tests. | Policy Guild | | 2025-11-23 | Completed POLICY-ENGINE-29-003: `/simulation/path-scope` endpoint returns NDJSON per contract with deterministic evaluation stub and tests. | Policy Guild | | 2025-11-23 | Completed POLICY-ENGINE-29-004: path-scope metrics (counters, duration histogram, cache/scope mismatches, per-tenant/source coverage gauge) and structured PathEval logs wired into evaluation flow; builds and targeted tests green. | Implementer | +| 2025-11-23 | Completed POLICY-ENGINE-30-001: overlay projection builder creates deterministic NDJSON snapshot (`overlay-projection-v1`) sorted by rule/subject/scope with evidence hashes and stable timestamps; service registered for downstream bridge. | Implementer | +| 2025-11-23 | Completed POLICY-ENGINE-30-002: simulation bridge stub produces ordered decisions/deltas from path inputs and overlays using deterministic seed; metrics echoed per prep schema. | Implementer | +| 2025-11-23 | Started POLICY-ENGINE-30-003: added change-event publisher scaffold and logging sink; overlay simulation endpoint exposed. | Implementer | | 2025-11-21 | Started path/scope schema draft for PREP-POLICY-ENGINE-29-002 at `docs/modules/policy/prep/2025-11-21-policy-path-scope-29-002-prep.md`; waiting on SBOM Service coordinate mapping rules. | Project Mgmt | | 2025-11-21 | Pinged Observability Guild for 29-004 metrics/logging outputs; drafting metrics/logging contract at `docs/modules/policy/prep/2025-11-21-policy-metrics-29-004-prep.md` while awaiting path/scope payloads from 29-003. | Project Mgmt | | 2025-11-20 | Confirmed no owners for PREP-POLICY-ENGINE-29-002/29-004/30-001/30-002/30-003; published prep notes in `docs/modules/policy/prep/` (files: 2025-11-20-policy-engine-29-002/29-004/30-001/30-002/30-003-prep.md); set P0–P4 DONE. | Implementer | diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/OverlaySimulationEndpoint.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/OverlaySimulationEndpoint.cs new file mode 100644 index 000000000..87bd08be0 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/OverlaySimulationEndpoint.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Policy.Engine.Overlay; + +namespace StellaOps.Policy.Engine.Endpoints; + +public static class OverlaySimulationEndpoint +{ + public static IEndpointRouteBuilder MapOverlaySimulation(this IEndpointRouteBuilder routes) + { + routes.MapPost("/simulation/overlay", HandleAsync) + .WithName("PolicyEngine.OverlaySimulation"); + + return routes; + } + + private static async Task HandleAsync( + [FromBody] PathScopeSimulationBridgeRequest request, + PathScopeSimulationBridgeService bridge, + CancellationToken cancellationToken) + { + var response = await bridge.SimulateAsync(request, cancellationToken).ConfigureAwait(false); + return Results.Json(response); + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/FileOverlayStore.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/FileOverlayStore.cs new file mode 100644 index 000000000..ed6c3a9b7 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/FileOverlayStore.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; + +namespace StellaOps.Policy.Engine.Overlay; + +/// +/// Persists overlay projections as NDJSON files under overlay/{tenant}/{ruleId}/{version}.ndjson. +/// +internal sealed class FileOverlayStore : IOverlayStore +{ + private readonly string _root; + + public FileOverlayStore(IHostEnvironment env) + { + _root = Path.Combine(env.ContentRootPath, "overlay"); + } + + public async Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default) + { + if (projection is null) + { + throw new ArgumentNullException(nameof(projection)); + } + + var tenant = Sanitize(projection.Tenant); + var rule = Sanitize(projection.RuleId); + var dir = Path.Combine(_root, tenant, rule); + Directory.CreateDirectory(dir); + + var path = Path.Combine(dir, $"{projection.Version}.ndjson"); + var lines = new[] + { + JsonSerializer.Serialize(new OverlayProjectionHeader("overlay-projection-v1")), + JsonSerializer.Serialize(projection) + }; + await File.WriteAllLinesAsync(path, lines, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + } + + private static string Sanitize(string value) + { + foreach (var ch in Path.GetInvalidFileNameChars()) + { + value = value.Replace(ch, '_'); + } + + return string.IsNullOrWhiteSpace(value) ? "unknown" : value; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/IOverlayStore.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/IOverlayStore.cs new file mode 100644 index 000000000..bdc664418 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/IOverlayStore.cs @@ -0,0 +1,6 @@ +namespace StellaOps.Policy.Engine.Overlay; + +internal interface IOverlayStore +{ + Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default); +} diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/LoggingOverlayEventSink.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/LoggingOverlayEventSink.cs new file mode 100644 index 000000000..a9abdd7fd --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/LoggingOverlayEventSink.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; + +namespace StellaOps.Policy.Engine.Overlay; + +/// +/// Stub sink that logs payloads; replace with bus implementation when available. +/// +internal sealed class LoggingOverlayEventSink : IOverlayEventSink +{ + private readonly ILogger _logger; + + public LoggingOverlayEventSink(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task PublishAsync(string topic, string payload, CancellationToken cancellationToken = default) + { + _logger.LogInformation("OverlayEvent {Topic} {Payload}", topic, payload); + return Task.CompletedTask; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayChangeEventPublisher.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayChangeEventPublisher.cs new file mode 100644 index 000000000..1723a2314 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayChangeEventPublisher.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace StellaOps.Policy.Engine.Overlay; + +internal interface IOverlayEventSink +{ + Task PublishAsync(string topic, string payload, CancellationToken cancellationToken = default); +} + +/// +/// Publishes overlay change events (POLICY-ENGINE-30-003). +/// Stub implementation writes to log sink; can be replaced with real bus publisher. +/// +internal sealed class OverlayChangeEventPublisher +{ + private const string Topic = "policy.overlay.changed"; + private readonly IOverlayEventSink _sink; + private readonly TimeProvider _timeProvider; + + public OverlayChangeEventPublisher(IOverlayEventSink sink, TimeProvider timeProvider) + { + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public Task PublishAsync(OverlayChangeEvent changeEvent, CancellationToken cancellationToken = default) + { + var payload = JsonSerializer.Serialize(changeEvent); + return _sink.PublishAsync(Topic, payload, cancellationToken); + } + + public OverlayChangeEvent BuildEvent( + string tenant, + OverlayProjection projection, + string changeType, + string correlationId, + PathDecisionDelta? delta = null) + { + var emittedAt = _timeProvider.GetUtcNow().ToString("O"); + return new OverlayChangeEvent( + Tenant: tenant, + RuleId: projection.RuleId, + Version: projection.Version, + ChangeType: changeType, + Projection: projection, + Delta: delta, + Artifacts: projection.Artifacts, + EmittedAt: emittedAt, + CorrelationId: correlationId, + IdempotencyKey: $"{tenant}:{projection.RuleId}:{projection.Version}"); + } +} + +internal sealed record OverlayChangeEvent( + string Tenant, + string RuleId, + int Version, + string ChangeType, + OverlayProjection Projection, + PathDecisionDelta? Delta, + OverlayArtifacts Artifacts, + string EmittedAt, + string CorrelationId, + string IdempotencyKey); diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayProjectionModels.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayProjectionModels.cs new file mode 100644 index 000000000..fb5ea7d72 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayProjectionModels.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using StellaOps.Policy.Engine.Streaming; + +namespace StellaOps.Policy.Engine.Overlay; + +internal sealed record OverlayProjectionHeader( + [property: JsonPropertyName("schemaVersion")] string SchemaVersion); + +internal sealed record OverlayProjection( + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("ruleId")] string RuleId, + [property: JsonPropertyName("subject")] PathScopeSubject Subject, + [property: JsonPropertyName("scope")] OverlayScope Scope, + [property: JsonPropertyName("decision")] string Decision, + [property: JsonPropertyName("reasons")] IReadOnlyList Reasons, + [property: JsonPropertyName("artifacts")] OverlayArtifacts Artifacts, + [property: JsonPropertyName("effectiveAt")] string EffectiveAt, + [property: JsonPropertyName("expiresAt")] string? ExpiresAt, + [property: JsonPropertyName("version")] int Version); + +internal sealed record OverlayScope( + [property: JsonPropertyName("pathMatch")] string PathMatch, + [property: JsonPropertyName("pattern")] string Pattern, + [property: JsonPropertyName("filePath")] string FilePath, + [property: JsonPropertyName("confidence")] double Confidence); + +internal sealed record OverlayArtifacts( + [property: JsonPropertyName("evidenceHash")] string? EvidenceHash, + [property: JsonPropertyName("treeDigest")] string? TreeDigest, + [property: JsonPropertyName("dsseEnvelopeHash")] string? DsseEnvelopeHash); diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayProjectionService.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayProjectionService.cs new file mode 100644 index 000000000..2c0af5cd4 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/OverlayProjectionService.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using StellaOps.Policy.Engine.Services; +using StellaOps.Policy.Engine.Streaming; + +namespace StellaOps.Policy.Engine.Overlay; + +/// +/// Builds deterministic overlay projection snapshots from path/scope simulation inputs (POLICY-ENGINE-30-001). +/// Outputs NDJSON lines sorted by ruleId, subject, scope, effectiveAt with a schema header. +/// +internal sealed class OverlayProjectionService +{ + private const string SchemaVersion = "overlay-projection-v1"; + private const string DefaultReason = "path-match"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + private readonly PolicyEvaluationService _evaluationService; + private readonly TimeProvider _timeProvider; + + public OverlayProjectionService(PolicyEvaluationService evaluationService, TimeProvider timeProvider) + { + _evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task> BuildSnapshotAsync( + PathScopeSimulationRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var orderedTargets = request.Targets + .OrderBy(t => t.FilePath, StringComparer.Ordinal) + .ThenBy(t => t.Pattern, StringComparer.Ordinal) + .ThenByDescending(t => t.Confidence) + .ToList(); + + var projections = new List(orderedTargets.Count); + var version = 1; + var effectiveAt = _timeProvider.GetUtcNow().ToString("O"); + + foreach (var target in orderedTargets) + { + cancellationToken.ThrowIfCancellationRequested(); + + var evaluation = await _evaluationService.EvaluatePathScopeAsync(request, target, cancellationToken) + .ConfigureAwait(false); + + var finding = evaluation["finding"]!; + var decision = finding?["verdict"]?["candidate"]?.GetValue() ?? "deny"; + + var projection = new OverlayProjection( + Tenant: request.Tenant, + RuleId: finding?["ruleId"]?.GetValue() ?? "policy.rules.path-scope.stub", + Subject: request.Subject, + Scope: new OverlayScope( + target.PathMatch, + target.Pattern, + target.FilePath, + target.Confidence), + Decision: decision, + Reasons: new[] { DefaultReason }, + Artifacts: new OverlayArtifacts( + EvidenceHash: target.EvidenceHash, + TreeDigest: target.TreeDigest, + DsseEnvelopeHash: null), + EffectiveAt: effectiveAt, + ExpiresAt: null, + Version: version++); + + projections.Add(projection); + } + + var ordered = projections + .OrderBy(p => p.RuleId, StringComparer.Ordinal) + .ThenBy(p => p.Subject.Purl ?? p.Subject.Cpe ?? string.Empty, StringComparer.Ordinal) + .ThenBy(p => p.Scope.PathMatch, StringComparer.Ordinal) + .ThenBy(p => p.Scope.Pattern, StringComparer.Ordinal) + .ThenBy(p => p.EffectiveAt, StringComparer.Ordinal) + .ToList(); + + var lines = new List(ordered.Count + 1) + { + JsonSerializer.Serialize(new OverlayProjectionHeader(SchemaVersion), SerializerOptions) + }; + + foreach (var projection in ordered) + { + lines.Add(JsonSerializer.Serialize(projection, SerializerOptions)); + } + + return lines; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/PathScopeSimulationBridgeModels.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/PathScopeSimulationBridgeModels.cs new file mode 100644 index 000000000..41a508d23 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/PathScopeSimulationBridgeModels.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using StellaOps.Policy.Engine.Streaming; + +namespace StellaOps.Policy.Engine.Overlay; + +internal sealed record PathScopeSimulationBridgeRequest( + [property: JsonPropertyName("tenant")] string Tenant, + [property: JsonPropertyName("rules")] IReadOnlyList Rules, + [property: JsonPropertyName("overlays")] IReadOnlyList? Overlays, + [property: JsonPropertyName("paths")] IReadOnlyList Paths, + [property: JsonPropertyName("mode")] string Mode, + [property: JsonPropertyName("seed")] int? Seed); + +internal sealed record PathScopeSimulationBridgeResponse( + [property: JsonPropertyName("decisions")] IReadOnlyList Decisions, + [property: JsonPropertyName("deltas")] IReadOnlyList? Deltas, + [property: JsonPropertyName("metrics")] SimulationMetrics Metrics); + +internal sealed record PathDecision( + [property: JsonPropertyName("pathScope")] PathScopeSimulationRequest PathScope, + [property: JsonPropertyName("decision")] string Decision, + [property: JsonPropertyName("reasons")] IReadOnlyList Reasons, + [property: JsonPropertyName("ruleId")] string RuleId, + [property: JsonPropertyName("version")] int Version, + [property: JsonPropertyName("effectiveAt")] string EffectiveAt); + +internal sealed record PathDecisionDelta( + [property: JsonPropertyName("ruleId")] string RuleId, + [property: JsonPropertyName("baselineDecision")] string BaselineDecision, + [property: JsonPropertyName("candidateDecision")] string CandidateDecision, + [property: JsonPropertyName("diffReason")] string DiffReason); + +internal sealed record SimulationMetrics( + [property: JsonPropertyName("evaluated")] int Evaluated, + [property: JsonPropertyName("allowed")] int Allowed, + [property: JsonPropertyName("denied")] int Denied, + [property: JsonPropertyName("warned")] int Warned, + [property: JsonPropertyName("deferred")] int Deferred); diff --git a/src/Policy/StellaOps.Policy.Engine/Overlay/PathScopeSimulationBridgeService.cs b/src/Policy/StellaOps.Policy.Engine/Overlay/PathScopeSimulationBridgeService.cs new file mode 100644 index 000000000..469e526b2 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Overlay/PathScopeSimulationBridgeService.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using System.Text; +using System.Security.Cryptography; +using StellaOps.Policy.Engine.Services; +using StellaOps.Policy.Engine.Streaming; + +namespace StellaOps.Policy.Engine.Overlay; + +/// +/// Simulation bridge (POLICY-ENGINE-30-002) that converts path/scope inputs into overlay decisions. +/// Deterministic stub: decisions mirror overlay projection decision for each path/rule. +/// +internal sealed class PathScopeSimulationBridgeService +{ + private const int DefaultSeed = unchecked((int)0xC0DEC0DE); + private readonly OverlayProjectionService _overlayService; + private readonly OverlayChangeEventPublisher _publisher; + private readonly IOverlayStore _store; + + public PathScopeSimulationBridgeService( + OverlayProjectionService overlayService, + OverlayChangeEventPublisher publisher, + IOverlayStore store) + { + _overlayService = overlayService ?? throw new ArgumentNullException(nameof(overlayService)); + _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + public async Task SimulateAsync( + PathScopeSimulationBridgeRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Paths is null || request.Paths.Count == 0) + { + return new PathScopeSimulationBridgeResponse(Array.Empty(), Array.Empty(), new SimulationMetrics(0, 0, 0, 0, 0)); + } + + var overlays = await BuildOverlaysAsync(request, cancellationToken).ConfigureAwait(false); + + var decisions = new List(request.Paths.Count); + var deltas = request.Mode == "whatif" ? new List(request.Paths.Count) : null; + + var seed = request.Seed ?? DefaultSeed; + var rng = new Random(seed); + + int allowed = 0, denied = 0, warned = 0, deferred = 0; + + var orderedPaths = request.Paths.Select((p, index) => (p, index)).OrderBy(x => x.index).ToList(); + + foreach (var (path, _) in orderedPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var overlay = overlays.First(); + var decision = overlay.Decision; + var reasons = overlay.Reasons; + var version = overlay.Version; + var effectiveAt = overlay.EffectiveAt; + decision = MutateDecisionDeterministically(decision, rng); + + decisions.Add(new PathDecision( + path, + decision, + reasons, + overlay.RuleId, + version, + effectiveAt)); + + Count(decision, ref allowed, ref denied, ref warned, ref deferred); + + if (deltas is not null) + { + var delta = new PathDecisionDelta( + overlay.RuleId, + BaselineDecision(decision), + decision, + "whatif-stub"); + deltas.Add(delta); + + var evt = _publisher.BuildEvent( + request.Tenant, + overlay, + changeType: "updated", + correlationId: BuildCorrelationId(request), + delta: delta); + await _publisher.PublishAsync(evt, cancellationToken).ConfigureAwait(false); + } + + await _store.SaveAsync(overlay, cancellationToken).ConfigureAwait(false); + } + + var metrics = new SimulationMetrics( + Evaluated: decisions.Count, + Allowed: allowed, + Denied: denied, + Warned: warned, + Deferred: deferred); + + return new PathScopeSimulationBridgeResponse( + decisions, + deltas, + metrics); + } + + private async Task> BuildOverlaysAsync( + PathScopeSimulationBridgeRequest request, + CancellationToken ct) + { + if (request.Overlays is not null && request.Overlays.Count > 0) + { + return request.Overlays.ToList(); + } + + // Build from the first path scope request to keep deterministic. + var firstPath = request.Paths.First(); + var lines = await _overlayService.BuildSnapshotAsync(firstPath, ct).ConfigureAwait(false); + var overlays = new List(); + foreach (var line in lines.Skip(1)) // skip header + { + overlays.Add(JsonSerializer.Deserialize(line)!); + } + + return overlays; + } + + private static void Count(string decision, ref int allowed, ref int denied, ref int warned, ref int deferred) + { + switch (decision) + { + case "allow": + allowed++; + break; + case "warn": + warned++; + break; + case "defer": + deferred++; + break; + default: + denied++; + break; + } + } + + private static string BaselineDecision(string decision) => decision == "allow" ? "deny" : decision; + + private static string MutateDecisionDeterministically(string decision, Random rng) + { + // Keep stable: 10% chance to warn instead of deny to simulate what-if variation. + var roll = rng.Next(0, 10); + if (decision == "deny" && roll == 0) + { + return "warn"; + } + + return decision; + } + + private static string BuildCorrelationId(PathScopeSimulationBridgeRequest request) + { + var stable = $"{request.Tenant}|{request.Mode}|{request.Seed ?? DefaultSeed}"; + Span hash = stackalloc byte[16]; + SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash); + return Convert.ToHexString(hash); + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Program.cs b/src/Policy/StellaOps.Policy.Engine/Program.cs index c809a2dd1..a195bd000 100644 --- a/src/Policy/StellaOps.Policy.Engine/Program.cs +++ b/src/Policy/StellaOps.Policy.Engine/Program.cs @@ -111,6 +111,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpContextAccessor(); @@ -159,5 +163,6 @@ app.MapGet("/", () => Results.Redirect("/healthz")); app.MapPolicyCompilation(); app.MapPolicyPacks(); app.MapPathScopeSimulation(); +app.MapOverlaySimulation(); app.Run(); diff --git a/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj b/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj index a84864a91..7c0f5dae9 100644 --- a/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj +++ b/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj @@ -1,14 +1,14 @@ - - - - net10.0 - enable - enable - preview - true - InProcess - - + + + + net10.0 + enable + enable + preview + true + InProcess + + @@ -18,4 +18,7 @@ + + + diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Fakes/FakeOverlayEventSink.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Fakes/FakeOverlayEventSink.cs new file mode 100644 index 000000000..da33293a8 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Fakes/FakeOverlayEventSink.cs @@ -0,0 +1,14 @@ +using StellaOps.Policy.Engine.Overlay; + +namespace StellaOps.Policy.Engine.Tests.Fakes; + +internal sealed class FakeOverlayEventSink : IOverlayEventSink +{ + public List<(string Topic, string Payload)> Published { get; } = new(); + + public Task PublishAsync(string topic, string payload, CancellationToken cancellationToken = default) + { + Published.Add((topic, payload)); + return Task.CompletedTask; + } +} diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Fakes/FakeOverlayStore.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Fakes/FakeOverlayStore.cs new file mode 100644 index 000000000..03a218790 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Fakes/FakeOverlayStore.cs @@ -0,0 +1,14 @@ +using StellaOps.Policy.Engine.Overlay; + +namespace StellaOps.Policy.Engine.Tests.Fakes; + +internal sealed class FakeOverlayStore : IOverlayStore +{ + public List Saved { get; } = new(); + + public Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default) + { + Saved.Add(projection); + return Task.CompletedTask; + } +} diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/OverlayProjectionServiceTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/OverlayProjectionServiceTests.cs new file mode 100644 index 000000000..13f0ab79c --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/OverlayProjectionServiceTests.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using StellaOps.Policy.Engine.Overlay; +using StellaOps.Policy.Engine.Services; +using StellaOps.Policy.Engine.Streaming; + +namespace StellaOps.Policy.Engine.Tests; + +public sealed class OverlayProjectionServiceTests +{ + [Fact] + public async Task BuildSnapshotAsync_ProducesHeaderAndSortedProjections() + { + var service = new OverlayProjectionService(new PolicyEvaluationService(), TimeProvider.System); + + var request = new PathScopeSimulationRequest( + SchemaVersion: "1.0.0", + Tenant: "acme", + BasePolicyRef: "policy://acme/main@sha256:1", + CandidatePolicyRef: "policy://acme/feat@sha256:2", + Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null), + Targets: new[] + { + new PathScopeTarget("b/file.js", "b/", "prefix", 0.5, null, null, null, "e2", null, null), + new PathScopeTarget("a/file.js", "a/", "prefix", 0.9, null, null, null, "e1", null, null) + }, + Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true)); + + var lines = await service.BuildSnapshotAsync(request); + + Assert.Equal(3, lines.Count); // header + 2 projections + Assert.Contains("overlay-projection-v1", lines[0]); + Assert.Contains("\"filePath\":\"a/file.js\"", lines[1]); + Assert.Contains("\"filePath\":\"b/file.js\"", lines[2]); + } +} diff --git a/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PathScopeSimulationBridgeServiceTests.cs b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PathScopeSimulationBridgeServiceTests.cs new file mode 100644 index 000000000..b9c09f357 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/PathScopeSimulationBridgeServiceTests.cs @@ -0,0 +1,100 @@ +using System.Threading.Tasks; +using StellaOps.Policy.Engine.Overlay; +using StellaOps.Policy.Engine.Tests.Fakes; +using StellaOps.Policy.Engine.Services; +using StellaOps.Policy.Engine.Streaming; + +namespace StellaOps.Policy.Engine.Tests; + +public sealed class PathScopeSimulationBridgeServiceTests +{ + [Fact] + public async Task SimulateAsync_OrdersByInputAndProducesMetrics() + { + var bridge = CreateBridge(); + + var request = new PathScopeSimulationBridgeRequest( + Tenant: "acme", + Rules: Array.Empty(), + Overlays: null, + Paths: new[] + { + BuildPathRequest("b/file.js"), + BuildPathRequest("a/file.js") + }, + Mode: "preview", + Seed: 123); + + 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.Equal(2, result.Metrics.Evaluated); + } + + [Fact] + public async Task SimulateAsync_WhatIfProducesDeltas() + { + var bridge = CreateBridge(); + + var request = new PathScopeSimulationBridgeRequest( + Tenant: "acme", + Rules: Array.Empty(), + Overlays: null, + Paths: new[] { BuildPathRequest("a/file.js") }, + Mode: "whatif", + Seed: 42); + + var result = await bridge.SimulateAsync(request); + + Assert.Single(result.Decisions); + Assert.Single(result.Deltas); + } + + [Fact] + public async Task SimulateAsync_PublishesEventsAndSavesOverlays() + { + var sink = new FakeOverlayEventSink(); + var store = new FakeOverlayStore(); + var bridge = CreateBridge(sink, store); + + var request = new PathScopeSimulationBridgeRequest( + Tenant: "acme", + Rules: Array.Empty(), + Overlays: null, + Paths: new[] { BuildPathRequest("a/file.js") }, + Mode: "whatif", + Seed: 99); + + await bridge.SimulateAsync(request); + + Assert.NotEmpty(sink.Published); + Assert.NotEmpty(store.Saved); + } + + private static PathScopeSimulationBridgeService CreateBridge( + IOverlayEventSink? sink = null, + IOverlayStore? store = null) + { + sink ??= new FakeOverlayEventSink(); + store ??= new FakeOverlayStore(); + + var overlayService = new OverlayProjectionService(new PolicyEvaluationService(), TimeProvider.System); + var publisher = new OverlayChangeEventPublisher(sink, TimeProvider.System); + return new PathScopeSimulationBridgeService(overlayService, publisher, store); + } + + private static PathScopeSimulationRequest BuildPathRequest(string filePath) => + new( + SchemaVersion: "1.0.0", + Tenant: "acme", + BasePolicyRef: "policy://acme/main@sha256:1", + CandidatePolicyRef: "policy://acme/feat@sha256:2", + Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null), + Targets: new[] + { + new PathScopeTarget(filePath, "pat", "prefix", 0.9, null, null, null, "e1", null, null) + }, + Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true)); +}