save progress

This commit is contained in:
StellaOps Bot
2026-01-04 14:54:52 +02:00
parent c49b03a254
commit 3098e84de4
132 changed files with 19783 additions and 31 deletions

View File

@@ -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.

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

View File

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