This commit is contained in:
StellaOps Bot
2025-11-24 07:49:18 +02:00
parent bb709b643e
commit 5970f0d9bd
16 changed files with 690 additions and 14 deletions

View File

@@ -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. <br><br> 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 P0P4 DONE. | Implementer |

View File

@@ -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<IResult> HandleAsync(
[FromBody] PathScopeSimulationBridgeRequest request,
PathScopeSimulationBridgeService bridge,
CancellationToken cancellationToken)
{
var response = await bridge.SimulateAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Json(response);
}
}

View File

@@ -0,0 +1,49 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
namespace StellaOps.Policy.Engine.Overlay;
/// <summary>
/// Persists overlay projections as NDJSON files under overlay/{tenant}/{ruleId}/{version}.ndjson.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Policy.Engine.Overlay;
internal interface IOverlayStore
{
Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,22 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.Overlay;
/// <summary>
/// Stub sink that logs payloads; replace with bus implementation when available.
/// </summary>
internal sealed class LoggingOverlayEventSink : IOverlayEventSink
{
private readonly ILogger<LoggingOverlayEventSink> _logger;
public LoggingOverlayEventSink(ILogger<LoggingOverlayEventSink> 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;
}
}

View File

@@ -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);
}
/// <summary>
/// Publishes overlay change events (POLICY-ENGINE-30-003).
/// Stub implementation writes to log sink; can be replaced with real bus publisher.
/// </summary>
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);

View File

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

View File

@@ -0,0 +1,97 @@
using System.Text.Json;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Overlay;
/// <summary>
/// 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.
/// </summary>
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<IReadOnlyList<string>> 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<OverlayProjection>(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<string>() ?? "deny";
var projection = new OverlayProjection(
Tenant: request.Tenant,
RuleId: finding?["ruleId"]?.GetValue<string>() ?? "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<string>(ordered.Count + 1)
{
JsonSerializer.Serialize(new OverlayProjectionHeader(SchemaVersion), SerializerOptions)
};
foreach (var projection in ordered)
{
lines.Add(JsonSerializer.Serialize(projection, SerializerOptions));
}
return lines;
}
}

View File

@@ -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<string> Rules,
[property: JsonPropertyName("overlays")] IReadOnlyList<OverlayProjection>? Overlays,
[property: JsonPropertyName("paths")] IReadOnlyList<PathScopeSimulationRequest> Paths,
[property: JsonPropertyName("mode")] string Mode,
[property: JsonPropertyName("seed")] int? Seed);
internal sealed record PathScopeSimulationBridgeResponse(
[property: JsonPropertyName("decisions")] IReadOnlyList<PathDecision> Decisions,
[property: JsonPropertyName("deltas")] IReadOnlyList<PathDecisionDelta>? Deltas,
[property: JsonPropertyName("metrics")] SimulationMetrics Metrics);
internal sealed record PathDecision(
[property: JsonPropertyName("pathScope")] PathScopeSimulationRequest PathScope,
[property: JsonPropertyName("decision")] string Decision,
[property: JsonPropertyName("reasons")] IReadOnlyList<string> 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);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<PathScopeSimulationBridgeResponse> 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<PathDecision>(), Array.Empty<PathDecisionDelta>(), new SimulationMetrics(0, 0, 0, 0, 0));
}
var overlays = await BuildOverlaysAsync(request, cancellationToken).ConfigureAwait(false);
var decisions = new List<PathDecision>(request.Paths.Count);
var deltas = request.Mode == "whatif" ? new List<PathDecisionDelta>(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<List<OverlayProjection>> 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<OverlayProjection>();
foreach (var line in lines.Skip(1)) // skip header
{
overlays.Add(JsonSerializer.Deserialize<OverlayProjection>(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<byte> hash = stackalloc byte[16];
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
return Convert.ToHexString(hash);
}
}

View File

@@ -111,6 +111,10 @@ builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddHttpContextAccessor();
@@ -159,5 +163,6 @@ app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.MapOverlaySimulation();
app.Run();

View File

@@ -18,4 +18,7 @@
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,14 @@
using StellaOps.Policy.Engine.Overlay;
namespace StellaOps.Policy.Engine.Tests.Fakes;
internal sealed class FakeOverlayStore : IOverlayStore
{
public List<OverlayProjection> Saved { get; } = new();
public Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default)
{
Saved.Add(projection);
return Task.CompletedTask;
}
}

View File

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

View File

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