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