Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,78 @@
-- =============================================================================
-- 012_budget_ledger.sql
-- Sprint: SPRINT_20251226_002_BE_budget_enforcement
-- Task: BUDGET-01 - Create budget_ledger PostgreSQL table
-- Description: Risk budget tracking tables
-- =============================================================================
-- Budget ledger: tracks risk budget allocation and consumption per service/window
CREATE TABLE IF NOT EXISTS policy.budget_ledger (
budget_id VARCHAR(256) PRIMARY KEY,
service_id VARCHAR(128) NOT NULL,
tenant_id VARCHAR(64),
tier INT NOT NULL DEFAULT 1,
window VARCHAR(16) NOT NULL,
allocated INT NOT NULL,
consumed INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'green',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Composite unique constraint
CONSTRAINT uq_budget_ledger_service_window UNIQUE (service_id, window)
);
-- Budget entries: individual consumption records
CREATE TABLE IF NOT EXISTS policy.budget_entries (
entry_id VARCHAR(64) PRIMARY KEY,
service_id VARCHAR(128) NOT NULL,
window VARCHAR(16) NOT NULL,
release_id VARCHAR(128) NOT NULL,
risk_points INT NOT NULL,
reason VARCHAR(512),
is_exception BOOLEAN NOT NULL DEFAULT FALSE,
penalty_points INT NOT NULL DEFAULT 0,
consumed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
consumed_by VARCHAR(256),
-- Foreign key to ledger (soft reference via service_id + window)
CONSTRAINT fk_budget_entries_ledger FOREIGN KEY (service_id, window)
REFERENCES policy.budget_ledger (service_id, window) ON DELETE CASCADE
);
-- Indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_budget_ledger_service_id ON policy.budget_ledger (service_id);
CREATE INDEX IF NOT EXISTS idx_budget_ledger_tenant_id ON policy.budget_ledger (tenant_id);
CREATE INDEX IF NOT EXISTS idx_budget_ledger_window ON policy.budget_ledger (window);
CREATE INDEX IF NOT EXISTS idx_budget_ledger_status ON policy.budget_ledger (status);
CREATE INDEX IF NOT EXISTS idx_budget_entries_service_window ON policy.budget_entries (service_id, window);
CREATE INDEX IF NOT EXISTS idx_budget_entries_release_id ON policy.budget_entries (release_id);
CREATE INDEX IF NOT EXISTS idx_budget_entries_consumed_at ON policy.budget_entries (consumed_at);
-- Enable Row-Level Security
ALTER TABLE policy.budget_ledger ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.budget_entries ENABLE ROW LEVEL SECURITY;
-- RLS policies for tenant isolation
CREATE POLICY budget_ledger_tenant_isolation ON policy.budget_ledger
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', TRUE) OR tenant_id IS NULL);
CREATE POLICY budget_entries_tenant_isolation ON policy.budget_entries
FOR ALL
USING (
EXISTS (
SELECT 1 FROM policy.budget_ledger bl
WHERE bl.service_id = budget_entries.service_id
AND bl.window = budget_entries.window
AND (bl.tenant_id = current_setting('app.tenant_id', TRUE) OR bl.tenant_id IS NULL)
)
);
-- Comments
COMMENT ON TABLE policy.budget_ledger IS 'Risk budget allocation and consumption per service/window';
COMMENT ON TABLE policy.budget_entries IS 'Individual budget consumption entries';
COMMENT ON COLUMN policy.budget_ledger.tier IS 'Service criticality tier: 0=Internal, 1=Customer-facing non-critical, 2=Customer-facing critical, 3=Safety/financial critical';
COMMENT ON COLUMN policy.budget_ledger.status IS 'Budget status: green (<40%), yellow (40-69%), red (70-99%), exhausted (>=100%)';
COMMENT ON COLUMN policy.budget_entries.is_exception IS 'Whether this was a break-glass/exception release';
COMMENT ON COLUMN policy.budget_entries.penalty_points IS 'Additional penalty points for exception releases';

View File

@@ -0,0 +1,174 @@
// -----------------------------------------------------------------------------
// BudgetLedgerEntity.cs
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
// Task: BUDGET-01 - Create budget_ledger PostgreSQL table
// Description: Entity for risk budget tracking
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a risk budget for a service within a time window.
/// Maps to policy.budget_ledger table.
/// </summary>
[Table("budget_ledger", Schema = "policy")]
public sealed class BudgetLedgerEntity
{
/// <summary>
/// Primary key - composite of service_id and window.
/// Format: "budget:{service_id}:{window}"
/// </summary>
[Key]
[MaxLength(256)]
[Column("budget_id")]
public required string BudgetId { get; init; }
/// <summary>
/// Service or product identifier.
/// </summary>
[Required]
[MaxLength(128)]
[Column("service_id")]
public required string ServiceId { get; init; }
/// <summary>
/// Tenant identifier for multi-tenant deployments.
/// </summary>
[MaxLength(64)]
[Column("tenant_id")]
public string? TenantId { get; init; }
/// <summary>
/// Service criticality tier (0-3).
/// </summary>
[Required]
[Column("tier")]
public int Tier { get; init; }
/// <summary>
/// Budget window identifier (e.g., "2025-12" for monthly).
/// </summary>
[Required]
[MaxLength(16)]
[Column("window")]
public required string Window { get; init; }
/// <summary>
/// Total risk points allocated for this window.
/// </summary>
[Required]
[Column("allocated")]
public int Allocated { get; init; }
/// <summary>
/// Risk points consumed so far.
/// </summary>
[Required]
[Column("consumed")]
public int Consumed { get; init; }
/// <summary>
/// Current budget status (green, yellow, red, exhausted).
/// </summary>
[Required]
[MaxLength(16)]
[Column("status")]
public required string Status { get; init; }
/// <summary>
/// When this budget was created.
/// </summary>
[Required]
[Column("created_at")]
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When this budget was last updated.
/// </summary>
[Required]
[Column("updated_at")]
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Entity representing a budget consumption entry.
/// Maps to policy.budget_entries table.
/// </summary>
[Table("budget_entries", Schema = "policy")]
public sealed class BudgetEntryEntity
{
/// <summary>
/// Primary key - unique entry identifier.
/// </summary>
[Key]
[MaxLength(64)]
[Column("entry_id")]
public required string EntryId { get; init; }
/// <summary>
/// Service identifier.
/// </summary>
[Required]
[MaxLength(128)]
[Column("service_id")]
public required string ServiceId { get; init; }
/// <summary>
/// Budget window (e.g., "2025-12").
/// </summary>
[Required]
[MaxLength(16)]
[Column("window")]
public required string Window { get; init; }
/// <summary>
/// Release or deployment identifier that consumed points.
/// </summary>
[Required]
[MaxLength(128)]
[Column("release_id")]
public required string ReleaseId { get; init; }
/// <summary>
/// Risk points consumed by this entry.
/// </summary>
[Required]
[Column("risk_points")]
public int RiskPoints { get; init; }
/// <summary>
/// Reason for consumption (optional).
/// </summary>
[MaxLength(512)]
[Column("reason")]
public string? Reason { get; init; }
/// <summary>
/// Whether this was an exception/break-glass entry.
/// </summary>
[Column("is_exception")]
public bool IsException { get; init; }
/// <summary>
/// Penalty points added (for exceptions).
/// </summary>
[Column("penalty_points")]
public int PenaltyPoints { get; init; }
/// <summary>
/// When this entry was recorded.
/// </summary>
[Required]
[Column("consumed_at")]
public DateTimeOffset ConsumedAt { get; init; }
/// <summary>
/// Actor who recorded this entry.
/// </summary>
[MaxLength(256)]
[Column("consumed_by")]
public string? ConsumedBy { get; init; }
}

View File

@@ -0,0 +1,315 @@
// -----------------------------------------------------------------------------
// PostgresBudgetStore.cs
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
// Task: BUDGET-02 - Implement BudgetLedgerRepository with CRUD + consumption
// Description: PostgreSQL implementation of IBudgetStore
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Gates;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of budget storage.
/// </summary>
public sealed class PostgresBudgetStore : RepositoryBase<PolicyDataSource>, IBudgetStore
{
/// <summary>
/// Creates a new PostgreSQL budget store.
/// </summary>
public PostgresBudgetStore(PolicyDataSource dataSource, ILogger<PostgresBudgetStore> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<RiskBudget?> GetAsync(string serviceId, string window, CancellationToken ct)
{
const string sql = """
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
FROM policy.budget_ledger
WHERE service_id = @service_id AND window = @window
""";
return await QuerySingleOrDefaultAsync(
null,
sql,
cmd =>
{
AddParameter(cmd, "service_id", serviceId);
AddParameter(cmd, "window", window);
},
MapRiskBudget,
ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task CreateAsync(RiskBudget budget, CancellationToken ct)
{
const string sql = """
INSERT INTO policy.budget_ledger (
budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
)
VALUES (
@budget_id, @service_id, @tenant_id, @tier, @window, @allocated, @consumed, @status, @created_at, @updated_at
)
ON CONFLICT (service_id, window) DO NOTHING
""";
await ExecuteAsync(
null,
sql,
cmd =>
{
AddParameter(cmd, "budget_id", budget.BudgetId);
AddParameter(cmd, "service_id", budget.ServiceId);
AddParameter(cmd, "tenant_id", (object?)null);
AddParameter(cmd, "tier", (int)budget.Tier);
AddParameter(cmd, "window", budget.Window);
AddParameter(cmd, "allocated", budget.Allocated);
AddParameter(cmd, "consumed", budget.Consumed);
AddParameter(cmd, "status", budget.Status.ToString().ToLowerInvariant());
AddParameter(cmd, "created_at", budget.UpdatedAt);
AddParameter(cmd, "updated_at", budget.UpdatedAt);
},
ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task UpdateAsync(RiskBudget budget, CancellationToken ct)
{
const string sql = """
UPDATE policy.budget_ledger
SET allocated = @allocated,
consumed = @consumed,
status = @status,
updated_at = @updated_at
WHERE service_id = @service_id AND window = @window
""";
await ExecuteAsync(
null,
sql,
cmd =>
{
AddParameter(cmd, "service_id", budget.ServiceId);
AddParameter(cmd, "window", budget.Window);
AddParameter(cmd, "allocated", budget.Allocated);
AddParameter(cmd, "consumed", budget.Consumed);
AddParameter(cmd, "status", budget.Status.ToString().ToLowerInvariant());
AddParameter(cmd, "updated_at", budget.UpdatedAt);
},
ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task AddEntryAsync(BudgetEntry entry, CancellationToken ct)
{
const string sql = """
INSERT INTO policy.budget_entries (
entry_id, service_id, window, release_id, risk_points, reason, is_exception, penalty_points, consumed_at, consumed_by
)
VALUES (
@entry_id, @service_id, @window, @release_id, @risk_points, @reason, @is_exception, @penalty_points, @consumed_at, @consumed_by
)
""";
await ExecuteAsync(
null,
sql,
cmd =>
{
AddParameter(cmd, "entry_id", entry.EntryId);
AddParameter(cmd, "service_id", entry.ServiceId);
AddParameter(cmd, "window", entry.Window);
AddParameter(cmd, "release_id", entry.ReleaseId);
AddParameter(cmd, "risk_points", entry.RiskPoints);
AddParameter(cmd, "reason", (object?)null);
AddParameter(cmd, "is_exception", false);
AddParameter(cmd, "penalty_points", 0);
AddParameter(cmd, "consumed_at", entry.ConsumedAt);
AddParameter(cmd, "consumed_by", (object?)null);
},
ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct)
{
const string sql = """
SELECT entry_id, service_id, window, release_id, risk_points, consumed_at
FROM policy.budget_entries
WHERE service_id = @service_id AND window = @window
ORDER BY consumed_at DESC
""";
return await QueryAsync(
null,
sql,
cmd =>
{
AddParameter(cmd, "service_id", serviceId);
AddParameter(cmd, "window", window);
},
MapBudgetEntry,
ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RiskBudget>> ListAsync(BudgetStatus? status, ServiceTier? tier, int limit, CancellationToken ct)
{
var sql = """
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
FROM policy.budget_ledger
WHERE 1=1
""";
if (status.HasValue)
{
sql += " AND status = @status";
}
if (tier.HasValue)
{
sql += " AND tier = @tier";
}
sql += " ORDER BY updated_at DESC LIMIT @limit";
return await QueryAsync(
null,
sql,
cmd =>
{
if (status.HasValue)
{
AddParameter(cmd, "status", status.Value.ToString().ToLowerInvariant());
}
if (tier.HasValue)
{
AddParameter(cmd, "tier", (int)tier.Value);
}
AddParameter(cmd, "limit", limit);
},
MapRiskBudget,
ct).ConfigureAwait(false);
}
/// <summary>
/// Get all budgets for a tenant within a time range.
/// </summary>
public async Task<IReadOnlyList<RiskBudget>> GetBudgetsByWindowAsync(
string? tenantId,
string windowStart,
string windowEnd,
CancellationToken ct)
{
var sql = """
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
FROM policy.budget_ledger
WHERE window >= @window_start AND window <= @window_end
""";
if (tenantId != null)
{
sql += " AND tenant_id = @tenant_id";
}
sql += " ORDER BY window DESC, service_id";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "window_start", windowStart);
AddParameter(cmd, "window_end", windowEnd);
if (tenantId != null)
{
AddParameter(cmd, "tenant_id", tenantId);
}
},
MapRiskBudget,
ct).ConfigureAwait(false);
}
/// <summary>
/// Get budgets by status.
/// </summary>
public async Task<IReadOnlyList<RiskBudget>> GetBudgetsByStatusAsync(
BudgetStatus status,
CancellationToken ct)
{
const string sql = """
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
FROM policy.budget_ledger
WHERE status = @status
ORDER BY updated_at DESC
""";
return await QueryAsync(
null,
sql,
cmd => AddParameter(cmd, "status", status.ToString().ToLowerInvariant()),
MapRiskBudget,
ct).ConfigureAwait(false);
}
/// <summary>
/// Reset budgets for a new window.
/// </summary>
public async Task<int> ResetForNewWindowAsync(string newWindow, CancellationToken ct)
{
const string sql = """
INSERT INTO policy.budget_ledger (
budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
)
SELECT
CONCAT('budget:', service_id, ':', @new_window),
service_id,
tenant_id,
tier,
@new_window,
allocated, -- Same allocation as previous window
0, -- Reset consumed to 0
'green', -- Reset status to green
NOW(),
NOW()
FROM policy.budget_ledger
WHERE window = (
SELECT MAX(window) FROM policy.budget_ledger WHERE window < @new_window
)
ON CONFLICT (service_id, window) DO NOTHING
""";
return await ExecuteAsync(
null,
sql,
cmd => AddParameter(cmd, "new_window", newWindow),
ct).ConfigureAwait(false);
}
private static RiskBudget MapRiskBudget(NpgsqlDataReader reader) => new()
{
BudgetId = reader.GetString(reader.GetOrdinal("budget_id")),
ServiceId = reader.GetString(reader.GetOrdinal("service_id")),
Tier = (ServiceTier)reader.GetInt32(reader.GetOrdinal("tier")),
Window = reader.GetString(reader.GetOrdinal("window")),
Allocated = reader.GetInt32(reader.GetOrdinal("allocated")),
Consumed = reader.GetInt32(reader.GetOrdinal("consumed")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
};
private static BudgetEntry MapBudgetEntry(NpgsqlDataReader reader) => new()
{
EntryId = reader.GetString(reader.GetOrdinal("entry_id")),
ServiceId = reader.GetString(reader.GetOrdinal("service_id")),
Window = reader.GetString(reader.GetOrdinal("window")),
ReleaseId = reader.GetString(reader.GetOrdinal("release_id")),
RiskPoints = reader.GetInt32(reader.GetOrdinal("risk_points")),
ConsumedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("consumed_at"))
};
}

View File

@@ -5,6 +5,9 @@ using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Storage.Postgres.Repositories;
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
// Use local repository interfaces (not the ones from StellaOps.Policy.Storage or StellaOps.Policy)
using ILocalRiskProfileRepository = StellaOps.Policy.Storage.Postgres.Repositories.IRiskProfileRepository;
using ILocalPolicyAuditRepository = StellaOps.Policy.Storage.Postgres.Repositories.IPolicyAuditRepository;
namespace StellaOps.Policy.Storage.Postgres;
@@ -32,13 +35,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IPackRepository, PackRepository>();
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
services.AddScoped<IRuleRepository, RuleRepository>();
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
services.AddScoped<ILocalRiskProfileRepository, RiskProfileRepository>();
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
services.AddScoped<IExceptionRepository, ExceptionRepository>();
services.AddScoped<IAuditableExceptionRepository, PostgresExceptionObjectRepository>();
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ILocalPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
@@ -65,13 +68,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IPackRepository, PackRepository>();
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
services.AddScoped<IRuleRepository, RuleRepository>();
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
services.AddScoped<ILocalRiskProfileRepository, RiskProfileRepository>();
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
services.AddScoped<IExceptionRepository, ExceptionRepository>();
services.AddScoped<IAuditableExceptionRepository, PostgresExceptionObjectRepository>();
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ILocalPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();

View File

@@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="..\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,136 @@
// -----------------------------------------------------------------------------
// GateBypassAuditEntry.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-06 - Gate bypass audit logging
// Description: Audit entry for gate bypass/override events
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Audit;
/// <summary>
/// Audit entry for gate bypass/override events.
/// Records who, when, and why a gate was overridden.
/// </summary>
public sealed record GateBypassAuditEntry
{
/// <summary>
/// Unique identifier for this audit entry.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// When the bypass occurred.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// The gate decision ID that was bypassed.
/// </summary>
public required string DecisionId { get; init; }
/// <summary>
/// The image digest being evaluated.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// The repository name.
/// </summary>
public string? Repository { get; init; }
/// <summary>
/// The tag, if any.
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// The baseline reference used for comparison.
/// </summary>
public string? BaselineRef { get; init; }
/// <summary>
/// The original gate decision before bypass.
/// </summary>
public required string OriginalDecision { get; init; }
/// <summary>
/// The decision after bypass (typically "Allow").
/// </summary>
public required string FinalDecision { get; init; }
/// <summary>
/// Which gate(s) were bypassed.
/// </summary>
public required IReadOnlyList<string> BypassedGates { get; init; }
/// <summary>
/// The identity of the user/service that requested the bypass.
/// </summary>
public required string Actor { get; init; }
/// <summary>
/// The subject identifier from the auth token.
/// </summary>
public string? ActorSubject { get; init; }
/// <summary>
/// The email associated with the actor, if available.
/// </summary>
public string? ActorEmail { get; init; }
/// <summary>
/// The IP address of the requester.
/// </summary>
public string? ActorIpAddress { get; init; }
/// <summary>
/// The justification provided for the bypass.
/// </summary>
public required string Justification { get; init; }
/// <summary>
/// The policy ID that was being evaluated.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// The source of the gate request (e.g., "cli", "api", "webhook").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// The CI/CD context, if available (e.g., "github-actions", "gitlab-ci").
/// </summary>
public string? CiContext { get; init; }
/// <summary>
/// Additional metadata about the bypass.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Bypass type classification.
/// </summary>
public enum GateBypassType
{
/// <summary>
/// Override applied to a warning-level gate.
/// </summary>
WarningOverride,
/// <summary>
/// Override applied to a blocking gate (requires elevated permission).
/// </summary>
BlockOverride,
/// <summary>
/// Emergency bypass with elevated privileges.
/// </summary>
EmergencyBypass,
/// <summary>
/// Time-limited bypass approval.
/// </summary>
TimeLimitedApproval
}

View File

@@ -0,0 +1,102 @@
// -----------------------------------------------------------------------------
// IGateBypassAuditRepository.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-06 - Gate bypass audit logging
// Description: Repository interface for gate bypass audit entries
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Audit;
/// <summary>
/// Repository for persisting and querying gate bypass audit entries.
/// </summary>
public interface IGateBypassAuditRepository
{
/// <summary>
/// Records a gate bypass audit entry.
/// </summary>
/// <param name="entry">The audit entry to record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddAsync(GateBypassAuditEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a bypass audit entry by ID.
/// </summary>
/// <param name="id">The entry ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The entry if found, null otherwise.</returns>
Task<GateBypassAuditEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets bypass audit entries by decision ID.
/// </summary>
/// <param name="decisionId">The gate decision ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of bypass entries for the decision.</returns>
Task<IReadOnlyList<GateBypassAuditEntry>> GetByDecisionIdAsync(
string decisionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets bypass audit entries by actor.
/// </summary>
/// <param name="actor">The actor identifier.</param>
/// <param name="limit">Maximum entries to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of bypass entries for the actor.</returns>
Task<IReadOnlyList<GateBypassAuditEntry>> GetByActorAsync(
string actor,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets bypass audit entries for an image digest.
/// </summary>
/// <param name="imageDigest">The image digest.</param>
/// <param name="limit">Maximum entries to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of bypass entries for the image.</returns>
Task<IReadOnlyList<GateBypassAuditEntry>> GetByImageDigestAsync(
string imageDigest,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists recent bypass audit entries.
/// </summary>
/// <param name="limit">Maximum entries to return.</param>
/// <param name="offset">Number of entries to skip.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of recent bypass entries.</returns>
Task<IReadOnlyList<GateBypassAuditEntry>> ListRecentAsync(
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists bypass audit entries within a time range.
/// </summary>
/// <param name="from">Start of time range (inclusive).</param>
/// <param name="to">End of time range (exclusive).</param>
/// <param name="limit">Maximum entries to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of bypass entries in the time range.</returns>
Task<IReadOnlyList<GateBypassAuditEntry>> ListByTimeRangeAsync(
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts bypass audit entries for an actor within a time window.
/// Used for rate limiting and abuse detection.
/// </summary>
/// <param name="actor">The actor identifier.</param>
/// <param name="since">Start of time window.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Count of bypass entries.</returns>
Task<int> CountByActorSinceAsync(
string actor,
DateTimeOffset since,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,144 @@
// -----------------------------------------------------------------------------
// InMemoryGateBypassAuditRepository.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-06 - Gate bypass audit logging
// Description: In-memory implementation of gate bypass audit repository
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
namespace StellaOps.Policy.Audit;
/// <summary>
/// In-memory implementation of <see cref="IGateBypassAuditRepository"/>.
/// Suitable for development and testing. Production should use PostgreSQL.
/// </summary>
public sealed class InMemoryGateBypassAuditRepository : IGateBypassAuditRepository
{
private readonly ConcurrentDictionary<Guid, GateBypassAuditEntry> _entries = new();
private readonly int _maxEntries;
public InMemoryGateBypassAuditRepository(int maxEntries = 10000)
{
_maxEntries = maxEntries;
}
/// <inheritdoc />
public Task AddAsync(GateBypassAuditEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
// Enforce max entries by removing oldest if at capacity
while (_entries.Count >= _maxEntries)
{
var oldest = _entries.Values
.OrderBy(e => e.Timestamp)
.FirstOrDefault();
if (oldest is not null)
{
_entries.TryRemove(oldest.Id, out _);
}
else
{
break;
}
}
_entries[entry.Id] = entry;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<GateBypassAuditEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
_entries.TryGetValue(id, out var entry);
return Task.FromResult(entry);
}
/// <inheritdoc />
public Task<IReadOnlyList<GateBypassAuditEntry>> GetByDecisionIdAsync(
string decisionId,
CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.Where(e => e.DecisionId == decisionId)
.OrderByDescending(e => e.Timestamp)
.ToList();
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
}
/// <inheritdoc />
public Task<IReadOnlyList<GateBypassAuditEntry>> GetByActorAsync(
string actor,
int limit = 100,
CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.Where(e => e.Actor == actor)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
}
/// <inheritdoc />
public Task<IReadOnlyList<GateBypassAuditEntry>> GetByImageDigestAsync(
string imageDigest,
int limit = 100,
CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.Where(e => e.ImageDigest == imageDigest)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
}
/// <inheritdoc />
public Task<IReadOnlyList<GateBypassAuditEntry>> ListRecentAsync(
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.OrderByDescending(e => e.Timestamp)
.Skip(offset)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
}
/// <inheritdoc />
public Task<IReadOnlyList<GateBypassAuditEntry>> ListByTimeRangeAsync(
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default)
{
var entries = _entries.Values
.Where(e => e.Timestamp >= from && e.Timestamp < to)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
}
/// <inheritdoc />
public Task<int> CountByActorSinceAsync(
string actor,
DateTimeOffset since,
CancellationToken cancellationToken = default)
{
var count = _entries.Values
.Count(e => e.Actor == actor && e.Timestamp >= since);
return Task.FromResult(count);
}
}

View File

@@ -210,6 +210,7 @@ public interface IBudgetStore
Task UpdateAsync(RiskBudget budget, CancellationToken ct);
Task AddEntryAsync(BudgetEntry entry, CancellationToken ct);
Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct);
Task<IReadOnlyList<RiskBudget>> ListAsync(BudgetStatus? status = null, ServiceTier? tier = null, int limit = 50, CancellationToken ct = default);
}
/// <summary>
@@ -275,4 +276,23 @@ public sealed class InMemoryBudgetStore : IBudgetStore
return Task.FromResult<IReadOnlyList<BudgetEntry>>(result);
}
}
public Task<IReadOnlyList<RiskBudget>> ListAsync(BudgetStatus? status, ServiceTier? tier, int limit, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
var query = _budgets.Values.AsEnumerable();
if (status.HasValue)
{
query = query.Where(b => b.Status == status.Value);
}
if (tier.HasValue)
{
query = query.Where(b => b.Tier == tier.Value);
}
var result = query.Take(limit).ToList();
return Task.FromResult<IReadOnlyList<RiskBudget>>(result);
}
}
}

View File

@@ -0,0 +1,180 @@
// -----------------------------------------------------------------------------
// BudgetThresholdNotifier.cs
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
// Task: BUDGET-06-07 - Budget threshold notifications
// Description: Publishes notification events when budget thresholds are crossed
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Publishes notification events when budget thresholds are crossed.
/// </summary>
public sealed class BudgetThresholdNotifier
{
private readonly INotifyEventPublisher _publisher;
private readonly ILogger<BudgetThresholdNotifier> _logger;
/// <summary>
/// Thresholds for different budget status levels.
/// </summary>
public static class Thresholds
{
/// <summary>Yellow threshold: 40%</summary>
public const decimal Yellow = 0.40m;
/// <summary>Red threshold: 70%</summary>
public const decimal Red = 0.70m;
/// <summary>Exhausted threshold: 100%</summary>
public const decimal Exhausted = 1.00m;
}
/// <summary>
/// Create a new budget threshold notifier.
/// </summary>
public BudgetThresholdNotifier(
INotifyEventPublisher publisher,
ILogger<BudgetThresholdNotifier> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Check if status has crossed a threshold and publish notification if needed.
/// </summary>
/// <param name="before">Budget status before the change.</param>
/// <param name="after">Budget status after the change.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
public async Task NotifyIfThresholdCrossedAsync(
RiskBudget before,
RiskBudget after,
string tenantId,
CancellationToken ct = default)
{
// Check if status has worsened
if (after.Status > before.Status)
{
await PublishThresholdCrossedAsync(before, after, tenantId, ct);
}
}
/// <summary>
/// Publish a warning notification when approaching threshold.
/// </summary>
public async Task NotifyWarningAsync(
RiskBudget budget,
string tenantId,
CancellationToken ct = default)
{
if (budget.Status >= BudgetStatus.Yellow)
{
var payload = CreatePayload(budget, "warning");
await _publisher.PublishAsync(
BudgetEventKinds.PolicyBudgetWarning,
tenantId,
payload,
ct);
_logger.LogInformation(
"Published budget warning for {ServiceId}: {PercentageUsed}% consumed",
budget.ServiceId,
budget.PercentageUsed);
}
}
/// <summary>
/// Publish an exceeded notification when budget is exhausted.
/// </summary>
public async Task NotifyExceededAsync(
RiskBudget budget,
string tenantId,
CancellationToken ct = default)
{
var payload = CreatePayload(budget, "exceeded");
await _publisher.PublishAsync(
BudgetEventKinds.PolicyBudgetExceeded,
tenantId,
payload,
ct);
_logger.LogWarning(
"Published budget exceeded for {ServiceId}: {PercentageUsed}% consumed",
budget.ServiceId,
budget.PercentageUsed);
}
private async Task PublishThresholdCrossedAsync(
RiskBudget before,
RiskBudget after,
string tenantId,
CancellationToken ct)
{
var eventKind = after.Status == BudgetStatus.Exhausted
? BudgetEventKinds.PolicyBudgetExceeded
: BudgetEventKinds.PolicyBudgetWarning;
var payload = CreatePayload(after, after.Status.ToString().ToLowerInvariant());
payload["previousStatus"] = before.Status.ToString().ToLowerInvariant();
await _publisher.PublishAsync(eventKind, tenantId, payload, ct);
_logger.LogInformation(
"Published budget threshold crossed for {ServiceId}: {PreviousStatus} -> {NewStatus}",
after.ServiceId,
before.Status,
after.Status);
}
private static JsonObject CreatePayload(RiskBudget budget, string severity)
{
return new JsonObject
{
["budgetId"] = budget.BudgetId,
["serviceId"] = budget.ServiceId,
["tier"] = budget.Tier.ToString().ToLowerInvariant(),
["window"] = budget.Window,
["allocated"] = budget.Allocated,
["consumed"] = budget.Consumed,
["remaining"] = budget.Remaining,
["percentageUsed"] = budget.PercentageUsed,
["status"] = budget.Status.ToString().ToLowerInvariant(),
["severity"] = severity,
["timestamp"] = DateTimeOffset.UtcNow.ToString("O")
};
}
}
/// <summary>
/// Known budget event kinds.
/// </summary>
public static class BudgetEventKinds
{
/// <summary>Budget warning threshold crossed.</summary>
public const string PolicyBudgetWarning = "policy.budget.warning";
/// <summary>Budget exhausted.</summary>
public const string PolicyBudgetExceeded = "policy.budget.exceeded";
}
/// <summary>
/// Interface for publishing notification events.
/// </summary>
public interface INotifyEventPublisher
{
/// <summary>
/// Publish a notification event.
/// </summary>
/// <param name="eventKind">Event kind identifier.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="payload">Event payload.</param>
/// <param name="ct">Cancellation token.</param>
Task PublishAsync(
string eventKind,
string tenantId,
JsonNode payload,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,446 @@
// -----------------------------------------------------------------------------
// EarnedCapacityReplenishment.cs
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
// Task: BUDGET-10 - Earned capacity replenishment
// Description: Grants budget increases based on performance improvement over time
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates;
/// <summary>
/// Evaluates service performance metrics to determine earned budget increases.
/// If MTTR and CFR improve for 2 consecutive windows, grants 10-20% budget increase.
/// </summary>
public sealed class EarnedCapacityEvaluator
{
private readonly IPerformanceMetricsStore _metricsStore;
private readonly IBudgetStore _budgetStore;
private readonly EarnedCapacityOptions _options;
/// <summary>
/// Create a new earned capacity evaluator.
/// </summary>
public EarnedCapacityEvaluator(
IPerformanceMetricsStore metricsStore,
IBudgetStore budgetStore,
EarnedCapacityOptions? options = null)
{
_metricsStore = metricsStore ?? throw new ArgumentNullException(nameof(metricsStore));
_budgetStore = budgetStore ?? throw new ArgumentNullException(nameof(budgetStore));
_options = options ?? new EarnedCapacityOptions();
}
/// <summary>
/// Evaluate if a service qualifies for earned capacity increase.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="currentWindow">Current budget window.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Evaluation result with eligibility and recommended increase.</returns>
public async Task<EarnedCapacityResult> EvaluateAsync(
string serviceId,
string currentWindow,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serviceId);
ArgumentException.ThrowIfNullOrWhiteSpace(currentWindow);
// Get historical windows to evaluate (current + 2 previous)
var windows = GetWindowSequence(currentWindow, _options.RequiredImprovementWindows + 1);
// Fetch metrics for each window
var metricsHistory = new List<WindowMetrics>();
foreach (var window in windows)
{
var metrics = await _metricsStore.GetMetricsAsync(serviceId, window, ct);
if (metrics != null)
{
metricsHistory.Add(metrics);
}
}
// Need at least 3 windows of data (current + 2 prior)
if (metricsHistory.Count < _options.RequiredImprovementWindows + 1)
{
return EarnedCapacityResult.NotEligible(
serviceId,
EarnedCapacityIneligibilityReason.InsufficientHistory,
$"Requires {_options.RequiredImprovementWindows + 1} windows of data, found {metricsHistory.Count}");
}
// Order by window (oldest first)
metricsHistory = metricsHistory.OrderBy(m => m.Window).ToList();
// Check for consistent improvement
var improvementCheck = CheckConsecutiveImprovement(metricsHistory);
if (!improvementCheck.IsImproving)
{
return EarnedCapacityResult.NotEligible(
serviceId,
EarnedCapacityIneligibilityReason.NoImprovement,
improvementCheck.Reason);
}
// Calculate recommended increase based on improvement magnitude
var increasePercentage = CalculateIncreasePercentage(
improvementCheck.MttrImprovementPercent,
improvementCheck.CfrImprovementPercent);
// Get current budget to calculate actual points
var currentBudget = await _budgetStore.GetAsync(serviceId, currentWindow, ct);
var currentAllocation = currentBudget?.Allocated
?? DefaultBudgetAllocations.GetMonthlyAllocation(ServiceTier.CustomerFacingNonCritical);
var additionalPoints = (int)Math.Ceiling(currentAllocation * increasePercentage / 100m);
return EarnedCapacityResult.Eligible(
serviceId,
increasePercentage,
additionalPoints,
improvementCheck.MttrImprovementPercent,
improvementCheck.CfrImprovementPercent);
}
/// <summary>
/// Apply an earned capacity increase to a service's budget.
/// </summary>
public async Task<RiskBudget> ApplyIncreaseAsync(
string serviceId,
string window,
int additionalPoints,
CancellationToken ct = default)
{
var budget = await _budgetStore.GetAsync(serviceId, window, ct)
?? throw new InvalidOperationException($"Budget not found for service {serviceId} window {window}");
var updatedBudget = budget with
{
Allocated = budget.Allocated + additionalPoints,
UpdatedAt = DateTimeOffset.UtcNow
};
await _budgetStore.UpdateAsync(updatedBudget, ct);
return updatedBudget;
}
private ImprovementCheckResult CheckConsecutiveImprovement(List<WindowMetrics> orderedMetrics)
{
// Compare each window to its predecessor
decimal totalMttrImprovement = 0;
decimal totalCfrImprovement = 0;
int improvingWindows = 0;
for (int i = 1; i < orderedMetrics.Count; i++)
{
var prev = orderedMetrics[i - 1];
var curr = orderedMetrics[i];
// Calculate MTTR improvement (lower is better)
var mttrImproved = prev.MttrHours > 0 && curr.MttrHours < prev.MttrHours;
var mttrImprovementPct = prev.MttrHours > 0
? (prev.MttrHours - curr.MttrHours) / prev.MttrHours * 100
: 0;
// Calculate CFR improvement (lower is better)
var cfrImproved = prev.ChangeFailureRate > 0 && curr.ChangeFailureRate < prev.ChangeFailureRate;
var cfrImprovementPct = prev.ChangeFailureRate > 0
? (prev.ChangeFailureRate - curr.ChangeFailureRate) / prev.ChangeFailureRate * 100
: 0;
// Both metrics must improve (or at least not regress significantly)
if (mttrImproved || (mttrImprovementPct >= -_options.RegressionTolerancePercent))
{
totalMttrImprovement += mttrImprovementPct;
}
else
{
return new ImprovementCheckResult(
false,
$"MTTR regressed in window {curr.Window}: {prev.MttrHours:F1}h -> {curr.MttrHours:F1}h",
0, 0);
}
if (cfrImproved || (cfrImprovementPct >= -_options.RegressionTolerancePercent))
{
totalCfrImprovement += cfrImprovementPct;
}
else
{
return new ImprovementCheckResult(
false,
$"CFR regressed in window {curr.Window}: {prev.ChangeFailureRate:F1}% -> {curr.ChangeFailureRate:F1}%",
0, 0);
}
// At least one metric must actually improve
if (mttrImproved || cfrImproved)
{
improvingWindows++;
}
}
// Need improvement for required consecutive windows
if (improvingWindows < _options.RequiredImprovementWindows)
{
return new ImprovementCheckResult(
false,
$"Required {_options.RequiredImprovementWindows} improving windows, found {improvingWindows}",
0, 0);
}
// Average improvement across windows
var avgMttrImprovement = totalMttrImprovement / (orderedMetrics.Count - 1);
var avgCfrImprovement = totalCfrImprovement / (orderedMetrics.Count - 1);
return new ImprovementCheckResult(true, null, avgMttrImprovement, avgCfrImprovement);
}
private decimal CalculateIncreasePercentage(decimal mttrImprovement, decimal cfrImprovement)
{
// Average of both improvements, clamped to min/max
var avgImprovement = (mttrImprovement + cfrImprovement) / 2;
// Scale: 10% improvement in metrics -> 10% budget increase
// 20%+ improvement -> 20% budget increase (capped)
var increase = Math.Min(avgImprovement, _options.MaxIncreasePercent);
return Math.Max(increase, _options.MinIncreasePercent);
}
private static IReadOnlyList<string> GetWindowSequence(string currentWindow, int count)
{
// Parse window format "YYYY-MM"
var windows = new List<string> { currentWindow };
if (currentWindow.Length >= 7 && currentWindow[4] == '-')
{
if (int.TryParse(currentWindow[..4], out var year) &&
int.TryParse(currentWindow[5..7], out var month))
{
for (int i = 1; i < count; i++)
{
month--;
if (month < 1)
{
month = 12;
year--;
}
windows.Add($"{year:D4}-{month:D2}");
}
}
}
return windows;
}
private sealed record ImprovementCheckResult(
bool IsImproving,
string? Reason,
decimal MttrImprovementPercent,
decimal CfrImprovementPercent);
}
/// <summary>
/// Result of earned capacity evaluation.
/// </summary>
public sealed record EarnedCapacityResult
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Whether the service is eligible for an increase.
/// </summary>
public required bool IsEligible { get; init; }
/// <summary>
/// Reason if not eligible.
/// </summary>
public EarnedCapacityIneligibilityReason? IneligibilityReason { get; init; }
/// <summary>
/// Description of ineligibility.
/// </summary>
public string? IneligibilityDescription { get; init; }
/// <summary>
/// Recommended increase percentage (10-20%).
/// </summary>
public decimal IncreasePercentage { get; init; }
/// <summary>
/// Recommended additional points to allocate.
/// </summary>
public int AdditionalPoints { get; init; }
/// <summary>
/// MTTR improvement over evaluation period.
/// </summary>
public decimal MttrImprovementPercent { get; init; }
/// <summary>
/// CFR improvement over evaluation period.
/// </summary>
public decimal CfrImprovementPercent { get; init; }
/// <summary>
/// Create a not-eligible result.
/// </summary>
public static EarnedCapacityResult NotEligible(
string serviceId,
EarnedCapacityIneligibilityReason reason,
string description) => new()
{
ServiceId = serviceId,
IsEligible = false,
IneligibilityReason = reason,
IneligibilityDescription = description
};
/// <summary>
/// Create an eligible result.
/// </summary>
public static EarnedCapacityResult Eligible(
string serviceId,
decimal increasePercentage,
int additionalPoints,
decimal mttrImprovement,
decimal cfrImprovement) => new()
{
ServiceId = serviceId,
IsEligible = true,
IncreasePercentage = increasePercentage,
AdditionalPoints = additionalPoints,
MttrImprovementPercent = mttrImprovement,
CfrImprovementPercent = cfrImprovement
};
}
/// <summary>
/// Reasons why a service is not eligible for earned capacity.
/// </summary>
public enum EarnedCapacityIneligibilityReason
{
/// <summary>
/// Not enough historical data.
/// </summary>
InsufficientHistory,
/// <summary>
/// Metrics did not improve.
/// </summary>
NoImprovement,
/// <summary>
/// Service is in probation period.
/// </summary>
InProbation,
/// <summary>
/// Manual override preventing increase.
/// </summary>
ManualOverride
}
/// <summary>
/// Performance metrics for a service in a budget window.
/// </summary>
public sealed record WindowMetrics
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Budget window.
/// </summary>
public required string Window { get; init; }
/// <summary>
/// Mean Time to Remediate in hours.
/// </summary>
public required decimal MttrHours { get; init; }
/// <summary>
/// Change Failure Rate as percentage (0-100).
/// </summary>
public required decimal ChangeFailureRate { get; init; }
/// <summary>
/// Number of deployments in the window.
/// </summary>
public int DeploymentCount { get; init; }
/// <summary>
/// Number of vulnerabilities remediated.
/// </summary>
public int VulnerabilitiesRemediated { get; init; }
/// <summary>
/// When metrics were calculated.
/// </summary>
public DateTimeOffset CalculatedAt { get; init; }
}
/// <summary>
/// Options for earned capacity evaluation.
/// </summary>
public sealed class EarnedCapacityOptions
{
/// <summary>
/// Number of consecutive improving windows required.
/// Default: 2.
/// </summary>
public int RequiredImprovementWindows { get; set; } = 2;
/// <summary>
/// Minimum budget increase percentage.
/// Default: 10%.
/// </summary>
public decimal MinIncreasePercent { get; set; } = 10m;
/// <summary>
/// Maximum budget increase percentage.
/// Default: 20%.
/// </summary>
public decimal MaxIncreasePercent { get; set; } = 20m;
/// <summary>
/// Tolerance for minor regression before disqualifying.
/// Default: 5% (allows 5% regression without disqualifying).
/// </summary>
public decimal RegressionTolerancePercent { get; set; } = 5m;
}
/// <summary>
/// Interface for performance metrics storage.
/// </summary>
public interface IPerformanceMetricsStore
{
/// <summary>
/// Get metrics for a service in a specific window.
/// </summary>
Task<WindowMetrics?> GetMetricsAsync(
string serviceId,
string window,
CancellationToken ct = default);
/// <summary>
/// Save or update metrics for a service.
/// </summary>
Task SaveMetricsAsync(
WindowMetrics metrics,
CancellationToken ct = default);
/// <summary>
/// List metrics for a service across windows.
/// </summary>
Task<IReadOnlyList<WindowMetrics>> ListMetricsAsync(
string serviceId,
int windowCount,
CancellationToken ct = default);
}
// IBudgetStore is defined in BudgetLedger.cs