// -----------------------------------------------------------------------------
// RiskBudgetEndpoints.cs
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
// Task: BUDGET-04 - Budget consumption API
// Description: API endpoints for risk budget management
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Endpoints;
///
/// API endpoints for risk budget management.
///
internal static class RiskBudgetEndpoints
{
public static IEndpointRouteBuilder MapRiskBudgets(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/policy/budget")
.RequireAuthorization()
.WithTags("Risk Budgets");
group.MapGet("/status/{serviceId}", GetBudgetStatus)
.WithName("GetRiskBudgetStatus")
.WithSummary("Get current risk budget status for a service.")
.Produces(StatusCodes.Status200OK);
group.MapPost("/consume", ConsumeBudget)
.WithName("ConsumeRiskBudget")
.WithSummary("Record budget consumption after a release.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/check", CheckRelease)
.WithName("CheckRelease")
.WithSummary("Check if a release can proceed given current budget.")
.Produces(StatusCodes.Status200OK);
group.MapGet("/history/{serviceId}", GetBudgetHistory)
.WithName("GetBudgetHistory")
.WithSummary("Get budget consumption history for a service.")
.Produces(StatusCodes.Status200OK);
group.MapPost("/adjust", AdjustBudget)
.WithName("AdjustBudget")
.WithSummary("Adjust budget allocation (earned capacity or manual override).")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
group.MapGet("/list", ListBudgets)
.WithName("ListRiskBudgets")
.WithSummary("List all risk budgets with optional filtering.")
.Produces(StatusCodes.Status200OK);
return endpoints;
}
private static async Task> GetBudgetStatus(
string serviceId,
[FromQuery] string? window,
IBudgetLedger ledger,
CancellationToken ct)
{
var budget = await ledger.GetBudgetAsync(serviceId, window, ct);
return TypedResults.Ok(new RiskBudgetStatusResponse(
budget.BudgetId,
budget.ServiceId,
budget.Tier.ToString(),
budget.Window,
budget.Allocated,
budget.Consumed,
budget.Remaining,
budget.PercentageUsed,
budget.Status.ToString().ToLowerInvariant(),
budget.UpdatedAt));
}
private static async Task, ProblemHttpResult>> ConsumeBudget(
[FromBody] BudgetConsumeRequest request,
IBudgetLedger ledger,
CancellationToken ct)
{
if (request.RiskPoints <= 0)
{
return TypedResults.Problem(
"Risk points must be greater than 0.",
statusCode: StatusCodes.Status400BadRequest);
}
var result = await ledger.ConsumeAsync(
request.ServiceId,
request.RiskPoints,
request.ReleaseId,
ct);
if (!result.IsSuccess)
{
return TypedResults.Problem(
result.Error ?? "Budget consumption failed.",
statusCode: StatusCodes.Status400BadRequest);
}
return TypedResults.Ok(new BudgetConsumeResponse(
result.IsSuccess,
result.Entry?.EntryId,
result.Budget.Remaining,
result.Budget.PercentageUsed,
result.Budget.Status.ToString().ToLowerInvariant(),
result.Error));
}
private static async Task> CheckRelease(
[FromBody] ReleaseCheckRequest request,
IBudgetConstraintEnforcer enforcer,
CancellationToken ct)
{
var input = new ReleaseCheckInput
{
ServiceId = request.ServiceId,
Tier = Enum.Parse(request.Tier, ignoreCase: true),
DiffCategory = Enum.Parse(request.DiffCategory, ignoreCase: true),
Context = new OperationalContext
{
// Map request properties to actual OperationalContext properties
InRestrictedWindow = request.ChangeFreeze || !request.DeploymentWindow,
HasRecentIncident = request.IncidentActive,
ErrorBudgetBelow50Percent = false, // Would come from budget ledger
HighOnCallLoad = false // Would come from external system
},
Mitigations = new MitigationFactors
{
HasFeatureFlag = request.HasFeatureFlag,
HasCanaryDeployment = request.CanaryPercentage > 0,
HasBackwardCompatibleMigration = request.HasRollbackPlan,
HasHighTestCoverage = false, // Would come from CI metadata
HasPermissionBoundary = request.IsNonProduction
}
};
var result = await enforcer.CheckReleaseAsync(input, ct);
return TypedResults.Ok(new ReleaseCheckResponse(
result.CanProceed,
result.RequiredGate.ToString().ToLowerInvariant(),
result.RiskPoints,
result.BudgetBefore.Remaining,
result.BudgetAfter.Remaining,
result.BudgetBefore.Status.ToString().ToLowerInvariant(),
result.BudgetAfter.Status.ToString().ToLowerInvariant(),
result.BlockReason,
result.Requirements,
result.Recommendations));
}
private static async Task> GetBudgetHistory(
string serviceId,
[FromQuery] string? window,
IBudgetLedger ledger,
CancellationToken ct)
{
var entries = await ledger.GetHistoryAsync(serviceId, window, ct);
var items = entries.Select(e => new BudgetEntryDto(
e.EntryId,
e.ReleaseId,
e.RiskPoints,
e.ConsumedAt)).ToList();
return TypedResults.Ok(new BudgetHistoryResponse(
serviceId,
window ?? GetCurrentWindow(),
items));
}
private static async Task, ProblemHttpResult>> AdjustBudget(
[FromBody] BudgetAdjustRequest request,
IBudgetLedger ledger,
CancellationToken ct)
{
if (request.Adjustment == 0)
{
return TypedResults.Problem(
"Adjustment must be non-zero.",
statusCode: StatusCodes.Status400BadRequest);
}
var budget = await ledger.AdjustAllocationAsync(
request.ServiceId,
request.Adjustment,
request.Reason,
ct);
return TypedResults.Ok(new RiskBudgetStatusResponse(
budget.BudgetId,
budget.ServiceId,
budget.Tier.ToString(),
budget.Window,
budget.Allocated,
budget.Consumed,
budget.Remaining,
budget.PercentageUsed,
budget.Status.ToString().ToLowerInvariant(),
budget.UpdatedAt));
}
private static Ok ListBudgets(
[FromQuery] string? status,
[FromQuery] string? window,
[FromQuery] int limit = 50)
{
// This would query from PostgresBudgetStore.GetBudgetsByStatusAsync or GetBudgetsByWindowAsync
// For now, return empty list - implementation would need to inject the store
return TypedResults.Ok(new BudgetListResponse([], 0));
}
private static string GetCurrentWindow() =>
DateTimeOffset.UtcNow.ToString("yyyy-MM");
}
#region DTOs
/// Response containing risk budget status.
public sealed record RiskBudgetStatusResponse(
string BudgetId,
string ServiceId,
string Tier,
string Window,
int Allocated,
int Consumed,
int Remaining,
decimal PercentageUsed,
string Status,
DateTimeOffset UpdatedAt);
/// Request to consume budget.
public sealed record BudgetConsumeRequest(
string ServiceId,
int RiskPoints,
string ReleaseId,
string? Reason = null);
/// Response from budget consumption.
public sealed record BudgetConsumeResponse(
bool IsSuccess,
string? EntryId,
int Remaining,
decimal PercentageUsed,
string Status,
string? Error);
/// Request to check if release can proceed.
public sealed record ReleaseCheckRequest(
string ServiceId,
string Tier,
string DiffCategory,
bool ChangeFreeze = false,
bool IncidentActive = false,
bool DeploymentWindow = true,
bool HasFeatureFlag = false,
int CanaryPercentage = 0,
bool HasRollbackPlan = false,
bool IsNonProduction = false);
/// Response from release check.
public sealed record ReleaseCheckResponse(
bool CanProceed,
string RequiredGate,
int RiskPoints,
int BudgetRemainingBefore,
int BudgetRemainingAfter,
string StatusBefore,
string StatusAfter,
string? BlockReason,
IReadOnlyList Requirements,
IReadOnlyList Recommendations);
/// Budget entry DTO.
public sealed record BudgetEntryDto(
string EntryId,
string ReleaseId,
int RiskPoints,
DateTimeOffset ConsumedAt);
/// Response containing budget history.
public sealed record BudgetHistoryResponse(
string ServiceId,
string Window,
IReadOnlyList Entries);
/// Request to adjust budget.
public sealed record BudgetAdjustRequest(
string ServiceId,
int Adjustment,
string Reason);
/// Response containing budget list.
public sealed record BudgetListResponse(
IReadOnlyList Budgets,
int TotalCount);
#endregion