Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -15,6 +15,7 @@ using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Gates;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
using StellaOps.Policy.Engine.Services;
@@ -232,6 +233,28 @@ public static class PolicyEngineServiceCollectionExtensions
return services.AddPolicyDecisionAttestation();
}
/// <summary>
/// Adds the execution evidence policy gate.
/// Enforces that artifacts have signed execution evidence before promotion.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
public static IServiceCollection AddExecutionEvidenceGate(this IServiceCollection services)
{
services.TryAddSingleton<IContextPolicyGate, ExecutionEvidenceGate>();
return services;
}
/// <summary>
/// Adds the beacon rate policy gate.
/// Enforces minimum beacon verification rate for runtime canary coverage.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
public static IServiceCollection AddBeaconRateGate(this IServiceCollection services)
{
services.TryAddSingleton<IContextPolicyGate, BeaconRateGate>();
return services;
}
/// <summary>
/// Adds build gate evaluators for exception recheck policies.
/// </summary>
@@ -328,6 +351,11 @@ public static class PolicyEngineServiceCollectionExtensions
// Always registered; activation controlled by PolicyEvidenceWeightedScoreOptions.Enabled
services.AddEvidenceWeightedScore();
// Execution evidence and beacon rate gates (Sprint: SPRINT_20260219_013/014)
// Always registered; activation controlled by PolicyGateOptions.ExecutionEvidence.Enabled / BeaconRate.Enabled
services.AddExecutionEvidenceGate();
services.AddBeaconRateGate();
return services;
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gates;
using System.Collections.Immutable;
using System.Globalization;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Policy gate that enforces beacon verification rate thresholds.
/// When enabled, blocks or warns for releases where beacon coverage is insufficient.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
public sealed class BeaconRateGate : IContextPolicyGate
{
private readonly IOptionsMonitor<PolicyGateOptions> _options;
private readonly ILogger<BeaconRateGate> _logger;
private readonly TimeProvider _timeProvider;
public string Id => "beacon-rate";
public string DisplayName => "Beacon Verification Rate";
public string Description => "Enforces minimum beacon verification rate for runtime canary coverage.";
public BeaconRateGate(
IOptionsMonitor<PolicyGateOptions> options,
ILogger<BeaconRateGate> logger,
TimeProvider? timeProvider = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
var gateOpts = _options.CurrentValue.BeaconRate;
if (!gateOpts.Enabled)
{
return Task.FromResult(GateResult.Pass(Id, "Beacon rate gate is disabled"));
}
var environment = context.Environment ?? "unknown";
// Check if environment requires beacon coverage.
if (!gateOpts.RequiredEnvironments.Contains(environment, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(GateResult.Pass(Id, $"Beacon rate not required for environment '{environment}'"));
}
// Read beacon data from context metadata.
double verificationRate = 0;
var hasBeaconData = context.Metadata?.TryGetValue("beacon_verification_rate", out var rateStr) == true
&& double.TryParse(rateStr, CultureInfo.InvariantCulture, out verificationRate);
if (!hasBeaconData)
{
_logger.LogInformation(
"BeaconRateGate: no beacon data for environment {Environment}, subject {Subject}",
environment, context.SubjectKey);
return gateOpts.MissingBeaconAction == PolicyGateDecisionType.Block
? Task.FromResult(GateResult.Fail(Id, "No beacon telemetry data available for this artifact",
ImmutableDictionary<string, object>.Empty
.Add("environment", environment)
.Add("suggestion", "Ensure beacon instrumentation is active in the target environment")))
: Task.FromResult(GateResult.Pass(Id, "No beacon data available (warn mode)",
new[] { "No beacon telemetry found; ensure runtime canary beacons are deployed" }));
}
// Check minimum beacon count before enforcing rate.
int beaconCount = 0;
var hasBeaconCount = context.Metadata?.TryGetValue("beacon_verified_count", out var countStr) == true
&& int.TryParse(countStr, out beaconCount);
if (hasBeaconCount && beaconCount < gateOpts.MinBeaconCount)
{
_logger.LogDebug(
"BeaconRateGate: insufficient beacon count ({Count} < {Min}) for {Environment}; skipping rate check",
beaconCount, gateOpts.MinBeaconCount, environment);
return Task.FromResult(GateResult.Pass(Id,
$"Beacon count ({beaconCount}) below minimum ({gateOpts.MinBeaconCount}); rate enforcement deferred",
new[] { $"Only {beaconCount} beacons observed; need {gateOpts.MinBeaconCount} before rate enforcement applies" }));
}
// Evaluate rate against threshold.
if (verificationRate < gateOpts.MinVerificationRate)
{
_logger.LogInformation(
"BeaconRateGate: rate {Rate:F4} below threshold {Threshold:F4} for {Environment}, subject {Subject}",
verificationRate, gateOpts.MinVerificationRate, environment, context.SubjectKey);
var details = ImmutableDictionary<string, object>.Empty
.Add("verification_rate", verificationRate)
.Add("threshold", gateOpts.MinVerificationRate)
.Add("environment", environment);
return gateOpts.BelowThresholdAction == PolicyGateDecisionType.Block
? Task.FromResult(GateResult.Fail(Id,
$"Beacon verification rate ({verificationRate:P1}) is below threshold ({gateOpts.MinVerificationRate:P1})",
details.Add("suggestion", "Investigate beacon gaps; possible deployment drift or instrumentation failure")))
: Task.FromResult(GateResult.Pass(Id,
$"Beacon verification rate ({verificationRate:P1}) is below threshold (warn mode)",
new[] { $"Beacon rate {verificationRate:P1} < {gateOpts.MinVerificationRate:P1}; investigate potential gaps" }));
}
_logger.LogDebug(
"BeaconRateGate: passed for {Environment}, rate={Rate:F4}, subject {Subject}",
environment, verificationRate, context.SubjectKey);
return Task.FromResult(GateResult.Pass(Id,
$"Beacon verification rate ({verificationRate:P1}) meets threshold ({gateOpts.MinVerificationRate:P1})"));
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gates;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Policy gate that enforces execution evidence requirements.
/// When enabled, blocks or warns for releases without signed execution evidence.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
public sealed class ExecutionEvidenceGate : IContextPolicyGate
{
private readonly IOptionsMonitor<PolicyGateOptions> _options;
private readonly ILogger<ExecutionEvidenceGate> _logger;
private readonly TimeProvider _timeProvider;
public string Id => "execution-evidence";
public string DisplayName => "Execution Evidence";
public string Description => "Requires signed execution evidence (runtime trace attestation) for production releases.";
public ExecutionEvidenceGate(
IOptionsMonitor<PolicyGateOptions> options,
ILogger<ExecutionEvidenceGate> logger,
TimeProvider? timeProvider = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
var gateOpts = _options.CurrentValue.ExecutionEvidence;
if (!gateOpts.Enabled)
{
return Task.FromResult(GateResult.Pass(Id, "Execution evidence gate is disabled"));
}
var environment = context.Environment ?? "unknown";
// Check if environment requires execution evidence.
if (!gateOpts.RequiredEnvironments.Contains(environment, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(GateResult.Pass(Id, $"Execution evidence not required for environment '{environment}'"));
}
// Check metadata for execution evidence fields.
var hasEvidence = context.Metadata?.TryGetValue("has_execution_evidence", out var evidenceStr) == true
&& string.Equals(evidenceStr, "true", StringComparison.OrdinalIgnoreCase);
if (!hasEvidence)
{
_logger.LogInformation(
"ExecutionEvidenceGate: missing execution evidence for environment {Environment}, subject {Subject}",
environment, context.SubjectKey);
return gateOpts.MissingEvidenceAction == PolicyGateDecisionType.Block
? Task.FromResult(GateResult.Fail(Id, "Signed execution evidence is required for production releases",
ImmutableDictionary<string, object>.Empty
.Add("environment", environment)
.Add("suggestion", "Run the execution evidence pipeline before promoting to production")))
: Task.FromResult(GateResult.Pass(Id, "Execution evidence missing (warn mode)",
new[] { "No signed execution evidence found; consider running the trace pipeline" }));
}
// Validate evidence quality if hot symbol count is provided.
if (context.Metadata?.TryGetValue("execution_evidence_hot_symbol_count", out var hotStr) == true
&& int.TryParse(hotStr, out var hotCount)
&& hotCount < gateOpts.MinHotSymbolCount)
{
_logger.LogInformation(
"ExecutionEvidenceGate: insufficient hot symbols ({Count} < {Min}) for environment {Environment}",
hotCount, gateOpts.MinHotSymbolCount, environment);
return Task.FromResult(GateResult.Pass(Id,
$"Execution evidence has insufficient coverage ({hotCount} hot symbols < {gateOpts.MinHotSymbolCount} minimum)",
new[] { $"Execution evidence trace has only {hotCount} hot symbols; minimum is {gateOpts.MinHotSymbolCount}" }));
}
// Validate unique call paths if provided.
if (context.Metadata?.TryGetValue("execution_evidence_unique_call_paths", out var pathStr) == true
&& int.TryParse(pathStr, out var pathCount)
&& pathCount < gateOpts.MinUniqueCallPaths)
{
return Task.FromResult(GateResult.Pass(Id,
$"Execution evidence has insufficient call path coverage ({pathCount} < {gateOpts.MinUniqueCallPaths})",
new[] { $"Execution evidence covers only {pathCount} unique call paths; minimum is {gateOpts.MinUniqueCallPaths}" }));
}
_logger.LogDebug(
"ExecutionEvidenceGate: passed for environment {Environment}, subject {Subject}",
environment, context.SubjectKey);
return Task.FromResult(GateResult.Pass(Id, "Signed execution evidence is present and meets quality thresholds"));
}
}

View File

@@ -151,6 +151,34 @@ public sealed record PolicyGateEvidence
/// </summary>
[JsonPropertyName("pathLength")]
public int? PathLength { get; init; }
/// <summary>
/// Whether signed execution evidence exists for this artifact.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
[JsonPropertyName("hasExecutionEvidence")]
public bool HasExecutionEvidence { get; init; }
/// <summary>
/// Execution evidence predicate digest (sha256:...), if available.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
[JsonPropertyName("executionEvidenceDigest")]
public string? ExecutionEvidenceDigest { get; init; }
/// <summary>
/// Beacon verification rate (0.0 - 1.0), if beacons are observed.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
[JsonPropertyName("beaconVerificationRate")]
public double? BeaconVerificationRate { get; init; }
/// <summary>
/// Total beacons verified in the lookback window.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
[JsonPropertyName("beaconCount")]
public int? BeaconCount { get; init; }
}
/// <summary>
@@ -366,4 +394,45 @@ public sealed record PolicyGateRequest
/// Signature verification method (dsse, cosign, pgp, x509).
/// </summary>
public string? VexSignatureMethod { get; init; }
// Execution evidence fields (added for ExecutionEvidenceGate integration)
// Sprint: SPRINT_20260219_013 (SEE-03)
/// <summary>
/// Whether signed execution evidence exists for this artifact.
/// </summary>
public bool HasExecutionEvidence { get; init; }
/// <summary>
/// Execution evidence predicate digest (sha256:...).
/// </summary>
public string? ExecutionEvidenceDigest { get; init; }
/// <summary>
/// Number of hot symbols in the execution evidence trace summary.
/// </summary>
public int? ExecutionEvidenceHotSymbolCount { get; init; }
/// <summary>
/// Number of unique call paths in the execution evidence.
/// </summary>
public int? ExecutionEvidenceUniqueCallPaths { get; init; }
// Beacon attestation fields (added for BeaconRateGate integration)
// Sprint: SPRINT_20260219_014 (BEA-03)
/// <summary>
/// Beacon verification rate (0.0 - 1.0) for this artifact/environment.
/// </summary>
public double? BeaconVerificationRate { get; init; }
/// <summary>
/// Total beacons verified in the lookback window.
/// </summary>
public int? BeaconVerifiedCount { get; init; }
/// <summary>
/// Total beacons expected in the lookback window.
/// </summary>
public int? BeaconExpectedCount { get; init; }
}

View File

@@ -43,6 +43,18 @@ public sealed class PolicyGateOptions
/// </summary>
public FacetQuotaGateOptions FacetQuota { get; set; } = new();
/// <summary>
/// Execution evidence gate options.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
public ExecutionEvidenceGateOptions ExecutionEvidence { get; set; } = new();
/// <summary>
/// Beacon verification rate gate options.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
public BeaconRateGateOptions BeaconRate { get; set; } = new();
/// <summary>
/// Whether gates are enabled.
/// </summary>
@@ -216,3 +228,80 @@ public sealed class FacetQuotaOverride
/// </summary>
public List<string> AllowlistGlobs { get; set; } = new();
}
/// <summary>
/// Configuration options for the execution evidence gate.
/// When enabled, requires signed execution evidence for production releases.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
public sealed class ExecutionEvidenceGateOptions
{
/// <summary>
/// Whether execution evidence enforcement is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Action when execution evidence is missing.
/// </summary>
public PolicyGateDecisionType MissingEvidenceAction { get; set; } = PolicyGateDecisionType.Warn;
/// <summary>
/// Environments where execution evidence is required (case-insensitive).
/// Default: production only.
/// </summary>
public List<string> RequiredEnvironments { get; set; } = new() { "production" };
/// <summary>
/// Minimum number of hot symbols to consider evidence meaningful.
/// Prevents trivial evidence from satisfying the gate.
/// </summary>
public int MinHotSymbolCount { get; set; } = 3;
/// <summary>
/// Minimum number of unique call paths to consider evidence meaningful.
/// </summary>
public int MinUniqueCallPaths { get; set; } = 1;
}
/// <summary>
/// Configuration options for the beacon verification rate gate.
/// When enabled, enforces minimum beacon coverage thresholds.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
public sealed class BeaconRateGateOptions
{
/// <summary>
/// Whether beacon rate enforcement is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Action when beacon rate is below threshold.
/// </summary>
public PolicyGateDecisionType BelowThresholdAction { get; set; } = PolicyGateDecisionType.Warn;
/// <summary>
/// Action when no beacon data exists for the artifact.
/// </summary>
public PolicyGateDecisionType MissingBeaconAction { get; set; } = PolicyGateDecisionType.Warn;
/// <summary>
/// Minimum beacon verification rate (0.0 - 1.0).
/// Beacon rate below this triggers the BelowThresholdAction.
/// Default: 0.8 (80% of expected beacons must be observed).
/// </summary>
public double MinVerificationRate { get; set; } = 0.8;
/// <summary>
/// Environments where beacon rate is enforced (case-insensitive).
/// Default: production only.
/// </summary>
public List<string> RequiredEnvironments { get; set; } = new() { "production" };
/// <summary>
/// Minimum number of beacons observed before rate enforcement applies.
/// Prevents premature gating on insufficient data.
/// </summary>
public int MinBeaconCount { get; set; } = 10;
}

View File

@@ -0,0 +1,317 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Persistence.Postgres.Repositories;
using System.Text.Json;
namespace StellaOps.Policy.Gateway.Endpoints;
/// <summary>
/// Advisory-source policy endpoints (impact and conflict facts).
/// </summary>
public static class AdvisorySourceEndpoints
{
private static readonly HashSet<string> AllowedConflictStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"open",
"resolved",
"dismissed"
};
public static void MapAdvisorySourcePolicyEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/advisory-sources")
.WithTags("Advisory Sources");
group.MapGet("/{sourceId}/impact", GetImpactAsync)
.WithName("GetAdvisorySourceImpact")
.WithDescription("Get policy impact facts for an advisory source.")
.Produces<AdvisorySourceImpactResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
group.MapGet("/{sourceId}/conflicts", GetConflictsAsync)
.WithName("GetAdvisorySourceConflicts")
.WithDescription("Get active/resolved advisory conflicts for a source.")
.Produces<AdvisorySourceConflictListResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
}
private static async Task<IResult> GetImpactAsync(
HttpContext httpContext,
[FromRoute] string sourceId,
[FromQuery] string? region,
[FromQuery] string? environment,
[FromQuery] string? sourceFamily,
[FromServices] IAdvisorySourcePolicyReadRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryGetTenantId(httpContext, out var tenantId))
{
return TenantMissingProblem();
}
if (string.IsNullOrWhiteSpace(sourceId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "sourceId is required.",
Status = StatusCodes.Status400BadRequest
});
}
var normalizedSourceId = sourceId.Trim();
var normalizedRegion = NormalizeOptional(region);
var normalizedEnvironment = NormalizeOptional(environment);
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
var impact = await repository.GetImpactAsync(
tenantId,
normalizedSourceId,
normalizedRegion,
normalizedEnvironment,
normalizedSourceFamily,
cancellationToken).ConfigureAwait(false);
var response = new AdvisorySourceImpactResponse
{
SourceId = normalizedSourceId,
SourceFamily = impact.SourceFamily ?? normalizedSourceFamily ?? string.Empty,
Region = normalizedRegion,
Environment = normalizedEnvironment,
ImpactedDecisionsCount = impact.ImpactedDecisionsCount,
ImpactSeverity = impact.ImpactSeverity,
LastDecisionAt = impact.LastDecisionAt,
DecisionRefs = ParseDecisionRefs(impact.DecisionRefsJson),
DataAsOf = timeProvider.GetUtcNow()
};
return Results.Ok(response);
}
private static async Task<IResult> GetConflictsAsync(
HttpContext httpContext,
[FromRoute] string sourceId,
[FromQuery] string? status,
[FromQuery] int? limit,
[FromQuery] int? offset,
[FromServices] IAdvisorySourcePolicyReadRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryGetTenantId(httpContext, out var tenantId))
{
return TenantMissingProblem();
}
if (string.IsNullOrWhiteSpace(sourceId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "sourceId is required.",
Status = StatusCodes.Status400BadRequest
});
}
var normalizedStatus = NormalizeOptional(status) ?? "open";
if (!AllowedConflictStatuses.Contains(normalizedStatus))
{
return Results.BadRequest(new ProblemDetails
{
Title = "status must be one of: open, resolved, dismissed.",
Status = StatusCodes.Status400BadRequest
});
}
var normalizedSourceId = sourceId.Trim();
var normalizedLimit = Math.Clamp(limit ?? 50, 1, 200);
var normalizedOffset = Math.Max(offset ?? 0, 0);
var page = await repository.ListConflictsAsync(
tenantId,
normalizedSourceId,
normalizedStatus,
normalizedLimit,
normalizedOffset,
cancellationToken).ConfigureAwait(false);
var items = page.Items.Select(static item => new AdvisorySourceConflictResponse
{
ConflictId = item.ConflictId,
AdvisoryId = item.AdvisoryId,
PairedSourceKey = item.PairedSourceKey,
ConflictType = item.ConflictType,
Severity = item.Severity,
Status = item.Status,
Description = item.Description,
FirstDetectedAt = item.FirstDetectedAt,
LastDetectedAt = item.LastDetectedAt,
ResolvedAt = item.ResolvedAt,
Details = ParseDetails(item.DetailsJson)
}).ToList();
return Results.Ok(new AdvisorySourceConflictListResponse
{
SourceId = normalizedSourceId,
Status = normalizedStatus,
Limit = normalizedLimit,
Offset = normalizedOffset,
TotalCount = page.TotalCount,
Items = items,
DataAsOf = timeProvider.GetUtcNow()
});
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static bool TryGetTenantId(HttpContext httpContext, out string tenantId)
{
tenantId = string.Empty;
var claimTenant = httpContext.User.Claims.FirstOrDefault(claim => claim.Type == "tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenantId = claimTenant.Trim();
return true;
}
var stellaHeaderTenant = httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(stellaHeaderTenant))
{
tenantId = stellaHeaderTenant.Trim();
return true;
}
var tenantHeader = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(tenantHeader))
{
tenantId = tenantHeader.Trim();
return true;
}
return false;
}
private static IResult TenantMissingProblem()
{
return Results.Problem(
title: "Tenant header required.",
detail: "Provide tenant via X-StellaOps-Tenant, X-Tenant-Id, or tenant_id claim.",
statusCode: StatusCodes.Status400BadRequest);
}
private static IReadOnlyList<AdvisorySourceDecisionRef> ParseDecisionRefs(string decisionRefsJson)
{
if (string.IsNullOrWhiteSpace(decisionRefsJson))
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
try
{
using var document = JsonDocument.Parse(decisionRefsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
var refs = new List<AdvisorySourceDecisionRef>();
foreach (var item in document.RootElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
refs.Add(new AdvisorySourceDecisionRef
{
DecisionId = TryGetString(item, "decisionId") ?? TryGetString(item, "decision_id") ?? string.Empty,
DecisionType = TryGetString(item, "decisionType") ?? TryGetString(item, "decision_type"),
Label = TryGetString(item, "label"),
Route = TryGetString(item, "route")
});
}
return refs;
}
catch (JsonException)
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
}
private static JsonElement? ParseDetails(string detailsJson)
{
if (string.IsNullOrWhiteSpace(detailsJson))
{
return null;
}
try
{
using var document = JsonDocument.Parse(detailsJson);
return document.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
private static string? TryGetString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
}
public sealed record AdvisorySourceImpactResponse
{
public string SourceId { get; init; } = string.Empty;
public string SourceFamily { get; init; } = string.Empty;
public string? Region { get; init; }
public string? Environment { get; init; }
public int ImpactedDecisionsCount { get; init; }
public string ImpactSeverity { get; init; } = "none";
public DateTimeOffset? LastDecisionAt { get; init; }
public IReadOnlyList<AdvisorySourceDecisionRef> DecisionRefs { get; init; } = Array.Empty<AdvisorySourceDecisionRef>();
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceDecisionRef
{
public string DecisionId { get; init; } = string.Empty;
public string? DecisionType { get; init; }
public string? Label { get; init; }
public string? Route { get; init; }
}
public sealed record AdvisorySourceConflictListResponse
{
public string SourceId { get; init; } = string.Empty;
public string Status { get; init; } = "open";
public int Limit { get; init; }
public int Offset { get; init; }
public int TotalCount { get; init; }
public IReadOnlyList<AdvisorySourceConflictResponse> Items { get; init; } = Array.Empty<AdvisorySourceConflictResponse>();
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceConflictResponse
{
public Guid ConflictId { get; init; }
public string AdvisoryId { get; init; } = string.Empty;
public string? PairedSourceKey { get; init; }
public string ConflictType { get; init; } = string.Empty;
public string Severity { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public DateTimeOffset FirstDetectedAt { get; init; }
public DateTimeOffset LastDetectedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public JsonElement? Details { get; init; }
}

View File

@@ -649,6 +649,9 @@ app.MapExceptionApprovalEndpoints();
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
app.MapGovernanceEndpoints();
// Advisory source impact/conflict endpoints (Sprint: SPRINT_20260219_008, Task: BE8-05)
app.MapAdvisorySourcePolicyEndpoints();
// Assistant tool lattice endpoints (Sprint: SPRINT_20260113_005_POLICY_assistant_tool_lattice)
app.MapToolLatticeEndpoints();

View File

@@ -46,6 +46,7 @@ public static class PolicyPersistenceExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
@@ -79,6 +80,7 @@ public static class PolicyPersistenceExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();

View File

@@ -0,0 +1,155 @@
-- Policy Schema Migration 005: Advisory source impact/conflict projection
-- Sprint: SPRINT_20260219_008
-- Task: BE8-05
CREATE TABLE IF NOT EXISTS policy.advisory_source_impacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_key TEXT NOT NULL,
source_family TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
environment TEXT NOT NULL DEFAULT '',
impacted_decisions_count INT NOT NULL DEFAULT 0 CHECK (impacted_decisions_count >= 0),
impact_severity TEXT NOT NULL DEFAULT 'none' CHECK (impact_severity IN ('none', 'low', 'medium', 'high', 'critical')),
last_decision_at TIMESTAMPTZ,
decision_refs JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_impacts_scope
ON policy.advisory_source_impacts (tenant_id, source_key, source_family, region, environment);
CREATE INDEX IF NOT EXISTS idx_advisory_source_impacts_lookup
ON policy.advisory_source_impacts (tenant_id, source_key, impact_severity, updated_at DESC);
DROP TRIGGER IF EXISTS trg_advisory_source_impacts_updated_at ON policy.advisory_source_impacts;
CREATE TRIGGER trg_advisory_source_impacts_updated_at
BEFORE UPDATE ON policy.advisory_source_impacts
FOR EACH ROW
EXECUTE FUNCTION policy.update_updated_at();
ALTER TABLE policy.advisory_source_impacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY advisory_source_impacts_tenant_isolation ON policy.advisory_source_impacts
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
CREATE TABLE IF NOT EXISTS policy.advisory_source_conflicts (
conflict_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
source_key TEXT NOT NULL,
source_family TEXT NOT NULL DEFAULT '',
advisory_id TEXT NOT NULL,
paired_source_key TEXT,
conflict_type TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
description TEXT NOT NULL DEFAULT '',
first_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
details_json JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_advisory_source_conflicts_open
ON policy.advisory_source_conflicts (tenant_id, source_key, advisory_id, conflict_type, COALESCE(paired_source_key, ''))
WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_lookup
ON policy.advisory_source_conflicts (tenant_id, source_key, status, severity, last_detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_advisory_source_conflicts_advisory
ON policy.advisory_source_conflicts (tenant_id, advisory_id, status);
DROP TRIGGER IF EXISTS trg_advisory_source_conflicts_updated_at ON policy.advisory_source_conflicts;
CREATE TRIGGER trg_advisory_source_conflicts_updated_at
BEFORE UPDATE ON policy.advisory_source_conflicts
FOR EACH ROW
EXECUTE FUNCTION policy.update_updated_at();
ALTER TABLE policy.advisory_source_conflicts ENABLE ROW LEVEL SECURITY;
CREATE POLICY advisory_source_conflicts_tenant_isolation ON policy.advisory_source_conflicts
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- Best-effort backfill from legacy policy.conflicts rows that encode source scope as source:<key>.
INSERT INTO policy.advisory_source_conflicts (
tenant_id,
source_key,
source_family,
advisory_id,
paired_source_key,
conflict_type,
severity,
status,
description,
first_detected_at,
last_detected_at,
details_json,
updated_by
)
SELECT
c.tenant_id,
split_part(c.affected_scope, ':', 2) AS source_key,
COALESCE(c.metadata->>'source_family', '') AS source_family,
COALESCE(c.metadata->>'advisory_id', 'unknown') AS advisory_id,
NULLIF(c.metadata->>'paired_source_key', '') AS paired_source_key,
c.conflict_type,
c.severity,
c.status,
c.description,
c.created_at AS first_detected_at,
COALESCE(c.resolved_at, c.created_at) AS last_detected_at,
c.metadata AS details_json,
'migration-005-backfill'
FROM policy.conflicts c
WHERE c.affected_scope LIKE 'source:%'
ON CONFLICT DO NOTHING;
INSERT INTO policy.advisory_source_impacts (
tenant_id,
source_key,
source_family,
impacted_decisions_count,
impact_severity,
last_decision_at,
decision_refs,
updated_by
)
SELECT
c.tenant_id,
c.source_key,
c.source_family,
COUNT(*)::INT AS impacted_decisions_count,
CASE MAX(
CASE c.severity
WHEN 'critical' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END)
WHEN 4 THEN 'critical'
WHEN 3 THEN 'high'
WHEN 2 THEN 'medium'
WHEN 1 THEN 'low'
ELSE 'none'
END AS impact_severity,
MAX(c.last_detected_at) AS last_decision_at,
'[]'::jsonb AS decision_refs,
'migration-005-backfill'
FROM policy.advisory_source_conflicts c
WHERE c.status = 'open'
GROUP BY c.tenant_id, c.source_key, c.source_family
ON CONFLICT (tenant_id, source_key, source_family, region, environment) DO UPDATE
SET
impacted_decisions_count = EXCLUDED.impacted_decisions_count,
impact_severity = EXCLUDED.impact_severity,
last_decision_at = EXCLUDED.last_decision_at,
updated_by = EXCLUDED.updated_by;

View File

@@ -0,0 +1,228 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed read model for advisory-source policy facts.
/// </summary>
public sealed class AdvisorySourcePolicyReadRepository
: RepositoryBase<PolicyDataSource>, IAdvisorySourcePolicyReadRepository
{
public AdvisorySourcePolicyReadRepository(
PolicyDataSource dataSource,
ILogger<AdvisorySourcePolicyReadRepository> logger)
: base(dataSource, logger)
{
}
public async Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
string tenantId,
string sourceKey,
string? region,
string? environment,
string? sourceFamily,
CancellationToken cancellationToken = default)
{
const string sql = """
WITH filtered AS (
SELECT
source_key,
source_family,
region,
environment,
impacted_decisions_count,
impact_severity,
last_decision_at,
updated_at,
decision_refs
FROM policy.advisory_source_impacts
WHERE tenant_id = @tenant_id
AND lower(source_key) = lower(@source_key)
AND (@region IS NULL OR lower(region) = lower(@region))
AND (@environment IS NULL OR lower(environment) = lower(@environment))
AND (@source_family IS NULL OR lower(source_family) = lower(@source_family))
),
aggregate_row AS (
SELECT
@source_key AS source_key,
@source_family AS source_family_filter,
@region AS region_filter,
@environment AS environment_filter,
COALESCE(SUM(impacted_decisions_count), 0)::INT AS impacted_decisions_count,
COALESCE(MAX(
CASE impact_severity
WHEN 'critical' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END
), 0) AS severity_rank,
MAX(last_decision_at) AS last_decision_at,
MAX(updated_at) AS updated_at,
COALESCE(
(SELECT decision_refs FROM filtered ORDER BY updated_at DESC NULLS LAST LIMIT 1),
'[]'::jsonb
)::TEXT AS decision_refs_json
FROM filtered
)
SELECT
source_key,
source_family_filter,
region_filter,
environment_filter,
impacted_decisions_count,
CASE severity_rank
WHEN 4 THEN 'critical'
WHEN 3 THEN 'high'
WHEN 2 THEN 'medium'
WHEN 1 THEN 'low'
ELSE 'none'
END AS impact_severity,
last_decision_at,
updated_at,
decision_refs_json
FROM aggregate_row;
""";
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
var normalizedRegion = NormalizeOptional(region);
var normalizedEnvironment = NormalizeOptional(environment);
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "source_key", normalizedSourceKey);
AddParameter(command, "region", normalizedRegion);
AddParameter(command, "environment", normalizedEnvironment);
AddParameter(command, "source_family", normalizedSourceFamily);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return new AdvisorySourceImpactSnapshot(
SourceKey: reader.GetString(0),
SourceFamily: GetNullableString(reader, 1),
Region: GetNullableString(reader, 2),
Environment: GetNullableString(reader, 3),
ImpactedDecisionsCount: reader.GetInt32(4),
ImpactSeverity: reader.GetString(5),
LastDecisionAt: GetNullableDateTimeOffset(reader, 6),
UpdatedAt: GetNullableDateTimeOffset(reader, 7),
DecisionRefsJson: reader.GetString(8));
}
public async Task<AdvisorySourceConflictPage> ListConflictsAsync(
string tenantId,
string sourceKey,
string? status,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
const string countSql = """
SELECT COUNT(*)::INT
FROM policy.advisory_source_conflicts
WHERE tenant_id = @tenant_id
AND lower(source_key) = lower(@source_key)
AND (@status IS NULL OR status = @status)
""";
const string listSql = """
SELECT
conflict_id,
advisory_id,
paired_source_key,
conflict_type,
severity,
status,
description,
first_detected_at,
last_detected_at,
resolved_at,
details_json::TEXT
FROM policy.advisory_source_conflicts
WHERE tenant_id = @tenant_id
AND lower(source_key) = lower(@source_key)
AND (@status IS NULL OR status = @status)
ORDER BY
CASE severity
WHEN 'critical' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END DESC,
last_detected_at DESC,
conflict_id
LIMIT @limit
OFFSET @offset
""";
var normalizedSourceKey = NormalizeRequired(sourceKey, nameof(sourceKey));
var normalizedStatus = NormalizeOptional(status);
var normalizedLimit = Math.Clamp(limit, 1, 200);
var normalizedOffset = Math.Max(offset, 0);
var totalCount = await ExecuteScalarAsync<int>(
tenantId,
countSql,
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "source_key", normalizedSourceKey);
AddParameter(command, "status", normalizedStatus);
},
cancellationToken).ConfigureAwait(false);
var items = await QueryAsync(
tenantId,
listSql,
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "source_key", normalizedSourceKey);
AddParameter(command, "status", normalizedStatus);
AddParameter(command, "limit", normalizedLimit);
AddParameter(command, "offset", normalizedOffset);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
return new AdvisorySourceConflictPage(items, totalCount);
}
private static AdvisorySourceConflictRecord MapConflict(NpgsqlDataReader reader)
{
return new AdvisorySourceConflictRecord(
ConflictId: reader.GetGuid(0),
AdvisoryId: reader.GetString(1),
PairedSourceKey: GetNullableString(reader, 2),
ConflictType: reader.GetString(3),
Severity: reader.GetString(4),
Status: reader.GetString(5),
Description: reader.GetString(6),
FirstDetectedAt: reader.GetFieldValue<DateTimeOffset>(7),
LastDetectedAt: reader.GetFieldValue<DateTimeOffset>(8),
ResolvedAt: GetNullableDateTimeOffset(reader, 9),
DetailsJson: reader.GetString(10));
}
private static string NormalizeRequired(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{parameterName} is required.", parameterName);
}
return value.Trim();
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,51 @@
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Read-model access for Advisory Sources policy-owned impact and conflict facts.
/// </summary>
public interface IAdvisorySourcePolicyReadRepository
{
Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
string tenantId,
string sourceKey,
string? region,
string? environment,
string? sourceFamily,
CancellationToken cancellationToken = default);
Task<AdvisorySourceConflictPage> ListConflictsAsync(
string tenantId,
string sourceKey,
string? status,
int limit,
int offset,
CancellationToken cancellationToken = default);
}
public sealed record AdvisorySourceImpactSnapshot(
string SourceKey,
string? SourceFamily,
string? Region,
string? Environment,
int ImpactedDecisionsCount,
string ImpactSeverity,
DateTimeOffset? LastDecisionAt,
DateTimeOffset? UpdatedAt,
string DecisionRefsJson);
public sealed record AdvisorySourceConflictRecord(
Guid ConflictId,
string AdvisoryId,
string? PairedSourceKey,
string ConflictType,
string Severity,
string Status,
string Description,
DateTimeOffset FirstDetectedAt,
DateTimeOffset LastDetectedAt,
DateTimeOffset? ResolvedAt,
string DetailsJson);
public sealed record AdvisorySourceConflictPage(
IReadOnlyList<AdvisorySourceConflictRecord> Items,
int TotalCount);

View File

@@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
@@ -81,6 +82,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<IAdvisorySourcePolicyReadRepository, AdvisorySourcePolicyReadRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();

View File

@@ -0,0 +1,230 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Gates;
/// <summary>
/// Tests for BeaconRateGate.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "20260219.014")]
public sealed class BeaconRateGateTests
{
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
new DateTimeOffset(2026, 2, 19, 14, 0, 0, TimeSpan.Zero));
#region Gate disabled
[Fact]
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
{
var gate = CreateGate(enabled: false);
var context = CreateContext("production");
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("disabled", result.Reason!);
}
#endregion
#region Environment filtering
[Fact]
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
{
var gate = CreateGate();
var context = CreateContext("development");
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("not required", result.Reason!);
}
#endregion
#region Missing beacon data
[Fact]
public async Task EvaluateAsync_MissingBeaconData_WarnMode_ReturnsPassWithWarning()
{
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
var context = CreateContext("production");
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EvaluateAsync_MissingBeaconData_BlockMode_ReturnsFail()
{
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
var context = CreateContext("production");
var result = await gate.EvaluateAsync(context);
Assert.False(result.Passed);
}
#endregion
#region Rate threshold enforcement
[Fact]
public async Task EvaluateAsync_RateAboveThreshold_ReturnsPass()
{
var gate = CreateGate(minRate: 0.8);
var context = CreateContext("production", beaconRate: 0.95, beaconCount: 100);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EvaluateAsync_RateBelowThreshold_WarnMode_ReturnsPassWithWarning()
{
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Warn);
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("below threshold", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EvaluateAsync_RateBelowThreshold_BlockMode_ReturnsFail()
{
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
var result = await gate.EvaluateAsync(context);
Assert.False(result.Passed);
}
#endregion
#region Minimum beacon count
[Fact]
public async Task EvaluateAsync_BelowMinBeaconCount_SkipsRateEnforcement()
{
var gate = CreateGate(minRate: 0.8, minBeaconCount: 50);
// Rate is bad but count is too low to enforce.
var context = CreateContext("production", beaconRate: 0.3, beaconCount: 5);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("deferred", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Boundary conditions
[Fact]
public async Task EvaluateAsync_ExactlyAtThreshold_ReturnsPass()
{
var gate = CreateGate(minRate: 0.8);
var context = CreateContext("production", beaconRate: 0.8, beaconCount: 100);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_JustBelowThreshold_TriggersAction()
{
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
var context = CreateContext("production", beaconRate: 0.7999, beaconCount: 100);
var result = await gate.EvaluateAsync(context);
Assert.False(result.Passed);
}
#endregion
#region Helpers
private BeaconRateGate CreateGate(
bool enabled = true,
double minRate = 0.8,
int minBeaconCount = 10,
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
PolicyGateDecisionType belowAction = PolicyGateDecisionType.Warn)
{
var opts = new PolicyGateOptions
{
BeaconRate = new BeaconRateGateOptions
{
Enabled = enabled,
MinVerificationRate = minRate,
MinBeaconCount = minBeaconCount,
MissingBeaconAction = missingAction,
BelowThresholdAction = belowAction,
RequiredEnvironments = new List<string> { "production" },
},
};
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
return new BeaconRateGate(monitor, NullLogger<BeaconRateGate>.Instance, _fixedTimeProvider);
}
private static PolicyGateContext CreateContext(
string environment,
double? beaconRate = null,
int? beaconCount = null)
{
var metadata = new Dictionary<string, string>();
if (beaconRate.HasValue)
{
metadata["beacon_verification_rate"] = beaconRate.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
if (beaconCount.HasValue)
{
metadata["beacon_verified_count"] = beaconCount.Value.ToString();
}
return new PolicyGateContext
{
Environment = environment,
SubjectKey = "test-subject",
Metadata = metadata,
};
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value) => _value = value;
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
#endregion
}

View File

@@ -0,0 +1,207 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Gates;
/// <summary>
/// Tests for ExecutionEvidenceGate.
/// Sprint: SPRINT_20260219_013 (SEE-03)
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "20260219.013")]
public sealed class ExecutionEvidenceGateTests
{
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
new DateTimeOffset(2026, 2, 19, 12, 0, 0, TimeSpan.Zero));
#region Gate disabled
[Fact]
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
{
var gate = CreateGate(enabled: false);
var context = CreateContext("production", hasEvidence: false);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("disabled", result.Reason!);
}
#endregion
#region Environment filtering
[Fact]
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
{
var gate = CreateGate();
var context = CreateContext("development", hasEvidence: false);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("not required", result.Reason!);
}
[Fact]
public async Task EvaluateAsync_RequiredEnvironment_EnforcesEvidence()
{
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
var context = CreateContext("production", hasEvidence: false);
var result = await gate.EvaluateAsync(context);
Assert.False(result.Passed);
Assert.Contains("required", result.Reason!);
}
#endregion
#region Evidence present
[Fact]
public async Task EvaluateAsync_EvidencePresent_ReturnsPass()
{
var gate = CreateGate();
var context = CreateContext("production", hasEvidence: true);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("present", result.Reason!);
}
#endregion
#region Missing evidence actions
[Fact]
public async Task EvaluateAsync_MissingEvidence_WarnMode_ReturnsPassWithWarning()
{
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
var context = CreateContext("production", hasEvidence: false);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EvaluateAsync_MissingEvidence_BlockMode_ReturnsFail()
{
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
var context = CreateContext("production", hasEvidence: false);
var result = await gate.EvaluateAsync(context);
Assert.False(result.Passed);
}
#endregion
#region Quality checks
[Fact]
public async Task EvaluateAsync_InsufficientHotSymbols_ReturnsPassWithWarning()
{
var gate = CreateGate(minHotSymbols: 10);
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 2);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("insufficient", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EvaluateAsync_SufficientHotSymbols_ReturnsCleanPass()
{
var gate = CreateGate(minHotSymbols: 3);
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 15);
var result = await gate.EvaluateAsync(context);
Assert.True(result.Passed);
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helpers
private ExecutionEvidenceGate CreateGate(
bool enabled = true,
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
int minHotSymbols = 3,
int minCallPaths = 1)
{
var opts = new PolicyGateOptions
{
ExecutionEvidence = new ExecutionEvidenceGateOptions
{
Enabled = enabled,
MissingEvidenceAction = missingAction,
MinHotSymbolCount = minHotSymbols,
MinUniqueCallPaths = minCallPaths,
RequiredEnvironments = new List<string> { "production" },
},
};
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
return new ExecutionEvidenceGate(monitor, NullLogger<ExecutionEvidenceGate>.Instance, _fixedTimeProvider);
}
private static PolicyGateContext CreateContext(
string environment,
bool hasEvidence,
int? hotSymbolCount = null,
int? uniqueCallPaths = null)
{
var metadata = new Dictionary<string, string>();
if (hasEvidence)
{
metadata["has_execution_evidence"] = "true";
}
if (hotSymbolCount.HasValue)
{
metadata["execution_evidence_hot_symbol_count"] = hotSymbolCount.Value.ToString();
}
if (uniqueCallPaths.HasValue)
{
metadata["execution_evidence_unique_call_paths"] = uniqueCallPaths.Value.ToString();
}
return new PolicyGateContext
{
Environment = environment,
SubjectKey = "test-subject",
Metadata = metadata,
};
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value) => _value = value;
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
#endregion
}

View File

@@ -0,0 +1,102 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using StellaOps.Policy.Gateway.Endpoints;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class AdvisorySourceEndpointsTests : IClassFixture<TestPolicyGatewayFactory>
{
private readonly TestPolicyGatewayFactory _factory;
public AdvisorySourceEndpointsTests(TestPolicyGatewayFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetImpact_ReturnsPolicyImpactPayload()
{
using var client = CreateTenantClient();
var response = await client.GetAsync(
"/api/v1/advisory-sources/nvd/impact?region=us-east&environment=prod&sourceFamily=nvd",
CancellationToken.None);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceImpactResponse>(cancellationToken: CancellationToken.None);
payload.Should().NotBeNull();
payload!.SourceId.Should().Be("nvd");
payload.ImpactedDecisionsCount.Should().Be(4);
payload.ImpactSeverity.Should().Be("high");
payload.DecisionRefs.Should().ContainSingle();
payload.DecisionRefs[0].DecisionId.Should().Be("APR-2201");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetImpact_WithoutTenant_ReturnsBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/impact", CancellationToken.None);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetConflicts_DefaultStatus_ReturnsOpenConflicts()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts", CancellationToken.None);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceConflictListResponse>(cancellationToken: CancellationToken.None);
payload.Should().NotBeNull();
payload!.SourceId.Should().Be("nvd");
payload.Status.Should().Be("open");
payload.TotalCount.Should().Be(1);
payload.Items.Should().ContainSingle();
payload.Items[0].AdvisoryId.Should().Be("CVE-2026-1188");
payload.Items[0].Status.Should().Be("open");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetConflicts_WithResolvedStatus_ReturnsResolvedConflicts()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts?status=resolved", CancellationToken.None);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceConflictListResponse>(cancellationToken: CancellationToken.None);
payload.Should().NotBeNull();
payload!.TotalCount.Should().Be(1);
payload.Items.Should().ContainSingle();
payload.Items[0].Status.Should().Be("resolved");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetConflicts_WithInvalidStatus_ReturnsBadRequest()
{
using var client = CreateTenantClient();
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/conflicts?status=invalid", CancellationToken.None);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
private HttpClient CreateTenantClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
return client;
}
}

View File

@@ -22,6 +22,7 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Persistence.Postgres.Repositories;
using System.Text.Json;
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
using GatewayProgram = StellaOps.Policy.Gateway.Program;
@@ -82,6 +83,8 @@ public sealed class TestPolicyGatewayFactory : WebApplicationFactory<GatewayProg
services.AddSingleton<IAuditableExceptionRepository, InMemoryExceptionRepository>();
services.RemoveAll<IGateDecisionHistoryRepository>();
services.AddSingleton<IGateDecisionHistoryRepository, InMemoryGateDecisionHistoryRepository>();
services.RemoveAll<IAdvisorySourcePolicyReadRepository>();
services.AddSingleton<IAdvisorySourcePolicyReadRepository, InMemoryAdvisorySourcePolicyReadRepository>();
// Override JWT bearer auth to accept test tokens without real OIDC discovery
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
@@ -330,3 +333,124 @@ internal sealed class InMemoryGateDecisionHistoryRepository : IGateDecisionHisto
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of advisory source policy read models for endpoint tests.
/// </summary>
internal sealed class InMemoryAdvisorySourcePolicyReadRepository : IAdvisorySourcePolicyReadRepository
{
private readonly AdvisorySourceImpactSnapshot _impact = new(
SourceKey: "nvd",
SourceFamily: "nvd",
Region: "us-east",
Environment: "prod",
ImpactedDecisionsCount: 4,
ImpactSeverity: "high",
LastDecisionAt: DateTimeOffset.Parse("2026-02-19T08:10:00Z"),
UpdatedAt: DateTimeOffset.Parse("2026-02-19T08:11:00Z"),
DecisionRefsJson: """
[
{
"decisionId": "APR-2201",
"decisionType": "approval",
"label": "Approval APR-2201",
"route": "/release-control/approvals/apr-2201"
}
]
""");
private readonly List<AdvisorySourceConflictRecord> _conflicts =
[
new(
ConflictId: Guid.Parse("49b08f4c-474e-4a88-9f71-b7f74572f9d3"),
AdvisoryId: "CVE-2026-1188",
PairedSourceKey: "ghsa",
ConflictType: "severity_mismatch",
Severity: "high",
Status: "open",
Description: "Severity mismatch between NVD and GHSA.",
FirstDetectedAt: DateTimeOffset.Parse("2026-02-19T07:40:00Z"),
LastDetectedAt: DateTimeOffset.Parse("2026-02-19T08:05:00Z"),
ResolvedAt: null,
DetailsJson: """{"lhs":"high","rhs":"critical"}"""),
new(
ConflictId: Guid.Parse("cb605891-90d5-4081-a17c-e55327ffce34"),
AdvisoryId: "CVE-2026-2001",
PairedSourceKey: "osv",
ConflictType: "remediation_mismatch",
Severity: "medium",
Status: "resolved",
Description: "Remediation mismatch resolved after triage.",
FirstDetectedAt: DateTimeOffset.Parse("2026-02-18T11:00:00Z"),
LastDetectedAt: DateTimeOffset.Parse("2026-02-18T13:15:00Z"),
ResolvedAt: DateTimeOffset.Parse("2026-02-18T14:00:00Z"),
DetailsJson: JsonSerializer.Serialize(new { resolution = "accepted_nvd", actor = "security-bot" }))
];
public Task<AdvisorySourceImpactSnapshot> GetImpactAsync(
string tenantId,
string sourceKey,
string? region,
string? environment,
string? sourceFamily,
CancellationToken cancellationToken = default)
{
if (!string.Equals(tenantId, "test-tenant", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(sourceKey, _impact.SourceKey, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new AdvisorySourceImpactSnapshot(
SourceKey: sourceKey,
SourceFamily: sourceFamily,
Region: region,
Environment: environment,
ImpactedDecisionsCount: 0,
ImpactSeverity: "none",
LastDecisionAt: null,
UpdatedAt: null,
DecisionRefsJson: "[]"));
}
return Task.FromResult(_impact with
{
Region = region ?? _impact.Region,
Environment = environment ?? _impact.Environment,
SourceFamily = sourceFamily ?? _impact.SourceFamily
});
}
public Task<AdvisorySourceConflictPage> ListConflictsAsync(
string tenantId,
string sourceKey,
string? status,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
if (!string.Equals(tenantId, "test-tenant", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(sourceKey, _impact.SourceKey, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(new AdvisorySourceConflictPage(Array.Empty<AdvisorySourceConflictRecord>(), 0));
}
var filtered = _conflicts
.Where(item => string.IsNullOrWhiteSpace(status) || string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(static item => item.Severity switch
{
"critical" => 4,
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 0
})
.ThenByDescending(static item => item.LastDetectedAt)
.ThenBy(static item => item.ConflictId)
.ToList();
var page = filtered
.Skip(Math.Max(offset, 0))
.Take(Math.Clamp(limit, 1, 200))
.ToList();
return Task.FromResult(new AdvisorySourceConflictPage(page, filtered.Count));
}
}