save progress
This commit is contained in:
@@ -9,6 +9,10 @@ Stand up the Policy Engine runtime host that evaluates organization policies aga
|
||||
- Change stream listeners and scheduler integration for incremental re-evaluation.
|
||||
- Authority integration enforcing new `policy:*` and `effective:write` scopes.
|
||||
- Observability: metrics, traces, structured logs, trace sampling.
|
||||
- **StabilityDampingGate** (Sprint NG-001): Hysteresis-based damping to prevent flip-flopping verdicts:
|
||||
- Suppresses rapid status oscillations requiring min duration or significant confidence delta
|
||||
- Upgrades (more severe) bypass damping; downgrades are dampable
|
||||
- Integrates with VexLens NoiseGate for unified noise-gating
|
||||
|
||||
## Expectations
|
||||
- Keep endpoints deterministic, cancellation-aware, and tenant-scoped.
|
||||
|
||||
384
src/Policy/StellaOps.Policy.Engine/Gates/StabilityDampingGate.cs
Normal file
384
src/Policy/StellaOps.Policy.Engine/Gates/StabilityDampingGate.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Gate that applies hysteresis-based stability damping to prevent flip-flopping verdicts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This gate tracks verdict state transitions and suppresses rapid oscillations by requiring:
|
||||
/// - A minimum duration before a state change is surfaced, OR
|
||||
/// - A significant confidence delta that justifies immediate surfacing
|
||||
///
|
||||
/// This reduces alert fatigue from noisy or unstable feed data while still ensuring
|
||||
/// significant changes (especially upgrades to more severe states) surface promptly.
|
||||
/// </remarks>
|
||||
public interface IStabilityDampingGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates whether a verdict transition should be surfaced or damped.
|
||||
/// </summary>
|
||||
/// <param name="request">The damping evaluation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The damping decision.</returns>
|
||||
Task<StabilityDampingDecision> EvaluateAsync(
|
||||
StabilityDampingRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a verdict state for future damping calculations.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique key for this verdict (e.g., "artifact:cve").</param>
|
||||
/// <param name="state">The verdict state to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordStateAsync(
|
||||
string key,
|
||||
VerdictState state,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Prunes old verdict history records.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of records pruned.</returns>
|
||||
Task<int> PruneHistoryAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for stability damping evaluation.
|
||||
/// </summary>
|
||||
public sealed record StabilityDampingRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique key for this verdict (e.g., "artifact:cve" or "sha256:vuln_id").
|
||||
/// </summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current (proposed) verdict state.
|
||||
/// </summary>
|
||||
public required VerdictState ProposedState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for multi-tenant deployments.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a verdict state at a point in time.
|
||||
/// </summary>
|
||||
public sealed record VerdictState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the VEX status (affected, not_affected, fixed, under_investigation).
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of this state.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale class (e.g., "authoritative", "binary", "static").
|
||||
/// </summary>
|
||||
public string? RationaleClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source that produced this state.
|
||||
/// </summary>
|
||||
public string? SourceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision from stability damping evaluation.
|
||||
/// </summary>
|
||||
public sealed record StabilityDampingDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the transition should be surfaced.
|
||||
/// </summary>
|
||||
public required bool ShouldSurface { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for the decision.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous state, if any.
|
||||
/// </summary>
|
||||
public VerdictState? PreviousState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets how long the previous state has persisted.
|
||||
/// </summary>
|
||||
public TimeSpan? StateDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence delta from previous to current.
|
||||
/// </summary>
|
||||
public double? ConfidenceDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a status upgrade (more severe).
|
||||
/// </summary>
|
||||
public bool? IsUpgrade { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the decision.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStabilityDampingGate"/>.
|
||||
/// </summary>
|
||||
public sealed class StabilityDampingGate : IStabilityDampingGate
|
||||
{
|
||||
private readonly IOptionsMonitor<StabilityDampingOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StabilityDampingGate> _logger;
|
||||
private readonly ConcurrentDictionary<string, VerdictState> _stateHistory = new();
|
||||
|
||||
// Status severity ordering: higher = more severe
|
||||
private static readonly Dictionary<string, int> StatusSeverity = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["affected"] = 100,
|
||||
["under_investigation"] = 50,
|
||||
["fixed"] = 25,
|
||||
["not_affected"] = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StabilityDampingGate"/> class.
|
||||
/// </summary>
|
||||
public StabilityDampingGate(
|
||||
IOptionsMonitor<StabilityDampingOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StabilityDampingGate> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<StabilityDampingDecision> EvaluateAsync(
|
||||
StabilityDampingRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var key = BuildKey(request.TenantId, request.Key);
|
||||
|
||||
// If disabled, always surface
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = "Stability damping disabled",
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check if status is subject to damping
|
||||
if (!opts.DampedStatuses.Contains(request.ProposedState.Status))
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Status '{request.ProposedState.Status}' is not subject to damping"),
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Get previous state
|
||||
if (!_stateHistory.TryGetValue(key, out var previousState))
|
||||
{
|
||||
// No history - this is a new verdict, surface it
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = "No previous state (new verdict)",
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check if status actually changed
|
||||
if (string.Equals(previousState.Status, request.ProposedState.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Same status - check confidence delta
|
||||
var confidenceDelta = Math.Abs(request.ProposedState.Confidence - previousState.Confidence);
|
||||
|
||||
if (confidenceDelta >= opts.MinConfidenceDeltaPercent)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Confidence changed by {confidenceDelta:P1} (threshold: {opts.MinConfidenceDeltaPercent:P1})"),
|
||||
PreviousState = previousState,
|
||||
ConfidenceDelta = confidenceDelta,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// No significant change
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = false,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Same status, confidence delta {confidenceDelta:P1} below threshold"),
|
||||
PreviousState = previousState,
|
||||
ConfidenceDelta = confidenceDelta,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Status changed - check if it's an upgrade or downgrade
|
||||
var isUpgrade = IsStatusUpgrade(previousState.Status, request.ProposedState.Status);
|
||||
var stateDuration = now - previousState.Timestamp;
|
||||
|
||||
// Upgrades (more severe) bypass damping if configured
|
||||
if (isUpgrade && opts.OnlyDampDowngrades)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Status upgrade ({previousState.Status} -> {request.ProposedState.Status}) surfaces immediately"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
IsUpgrade = true,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check confidence delta for immediate surfacing
|
||||
var delta = Math.Abs(request.ProposedState.Confidence - previousState.Confidence);
|
||||
if (delta >= opts.MinConfidenceDeltaPercent)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Confidence delta {delta:P1} exceeds threshold {opts.MinConfidenceDeltaPercent:P1}"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
ConfidenceDelta = delta,
|
||||
IsUpgrade = isUpgrade,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check duration requirement
|
||||
if (stateDuration >= opts.MinDurationBeforeChange)
|
||||
{
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = true,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Previous state persisted for {stateDuration.TotalHours:F1}h (threshold: {opts.MinDurationBeforeChange.TotalHours:F1}h)"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
IsUpgrade = isUpgrade,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Damped - don't surface yet
|
||||
var remainingTime = opts.MinDurationBeforeChange - stateDuration;
|
||||
|
||||
if (opts.LogDampedTransitions)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Damped transition for {Key}: {OldStatus}->{NewStatus}, remaining: {Remaining}",
|
||||
request.Key,
|
||||
previousState.Status,
|
||||
request.ProposedState.Status,
|
||||
remainingTime);
|
||||
}
|
||||
|
||||
return Task.FromResult(new StabilityDampingDecision
|
||||
{
|
||||
ShouldSurface = false,
|
||||
Reason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"Damped: state duration {stateDuration.TotalHours:F1}h < {opts.MinDurationBeforeChange.TotalHours:F1}h, " +
|
||||
$"delta {delta:P1} < {opts.MinConfidenceDeltaPercent:P1}"),
|
||||
PreviousState = previousState,
|
||||
StateDuration = stateDuration,
|
||||
ConfidenceDelta = delta,
|
||||
IsUpgrade = isUpgrade,
|
||||
DecidedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RecordStateAsync(
|
||||
string key,
|
||||
VerdictState state,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
_stateHistory[key] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<int> PruneHistoryAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
var cutoff = _timeProvider.GetUtcNow() - opts.HistoryRetention;
|
||||
var pruned = 0;
|
||||
|
||||
foreach (var kvp in _stateHistory)
|
||||
{
|
||||
if (kvp.Value.Timestamp < cutoff)
|
||||
{
|
||||
if (_stateHistory.TryRemove(kvp.Key, out _))
|
||||
{
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0)
|
||||
{
|
||||
_logger.LogInformation("Pruned {Count} stale verdict state records", pruned);
|
||||
}
|
||||
|
||||
return Task.FromResult(pruned);
|
||||
}
|
||||
|
||||
private static string BuildKey(string? tenantId, string verdictKey)
|
||||
{
|
||||
return string.IsNullOrEmpty(tenantId)
|
||||
? verdictKey
|
||||
: $"{tenantId}:{verdictKey}";
|
||||
}
|
||||
|
||||
private static bool IsStatusUpgrade(string oldStatus, string newStatus)
|
||||
{
|
||||
var oldSeverity = StatusSeverity.GetValueOrDefault(oldStatus, 50);
|
||||
var newSeverity = StatusSeverity.GetValueOrDefault(newStatus, 50);
|
||||
return newSeverity > oldSeverity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the stability damping gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stability damping prevents flip-flopping verdicts by requiring that:
|
||||
/// - A verdict must persist for a minimum duration before a change is surfaced, OR
|
||||
/// - The confidence delta must exceed a minimum threshold
|
||||
///
|
||||
/// This reduces notification noise from unstable feed data while still allowing
|
||||
/// significant changes to surface quickly.
|
||||
/// </remarks>
|
||||
public sealed class StabilityDampingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether stability damping is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum duration a verdict must persist before
|
||||
/// a change is surfaced, unless the confidence delta exceeds the threshold.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default: 4 hours. Set to TimeSpan.Zero to disable duration-based damping.
|
||||
/// </remarks>
|
||||
[Range(typeof(TimeSpan), "00:00:00", "7.00:00:00",
|
||||
ErrorMessage = "MinDurationBeforeChange must be between 0 and 7 days.")]
|
||||
public TimeSpan MinDurationBeforeChange { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum confidence change percentage required to
|
||||
/// bypass the duration requirement and surface a change immediately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default: 15%. A change from 0.70 to 0.85 (15%) would bypass duration damping.
|
||||
/// Set to 1.0 (100%) to effectively disable delta-based bypass.
|
||||
/// </remarks>
|
||||
[Range(0.0, 1.0, ErrorMessage = "MinConfidenceDeltaPercent must be between 0 and 1.")]
|
||||
public double MinConfidenceDeltaPercent { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the VEX statuses to which damping applies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, damping applies to affected and not_affected transitions.
|
||||
/// Transitions to/from under_investigation are typically not damped.
|
||||
/// </remarks>
|
||||
public HashSet<string> DampedStatuses { get; set; } =
|
||||
[
|
||||
"affected",
|
||||
"not_affected",
|
||||
"fixed"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to apply damping only to downgrade transitions
|
||||
/// (e.g., affected -> not_affected).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When true, upgrades (not_affected -> affected) are surfaced immediately.
|
||||
/// This is conservative: users are alerted to new risks without delay.
|
||||
/// </remarks>
|
||||
public bool OnlyDampDowngrades { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the retention period for verdict history.
|
||||
/// Older records are pruned to prevent unbounded growth.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryRetention { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to log damped transitions for debugging.
|
||||
/// </summary>
|
||||
public bool LogDampedTransitions { get; set; } = false;
|
||||
}
|
||||
Reference in New Issue
Block a user