// ----------------------------------------------------------------------------- // ReplayEndpoints.cs // Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure // Task: GR-002 - Implement POST /replay endpoint // Description: API endpoints for policy decision replay and verification // ----------------------------------------------------------------------------- using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Policy.Persistence.Postgres.Repositories; namespace StellaOps.Policy.Api.Endpoints; /// /// API endpoints for policy decision replay. /// Enables "months later you can re-prove why a release passed/failed". /// public static class ReplayEndpoints { /// /// Maps replay API endpoints. /// public static IEndpointRouteBuilder MapReplayEndpoints(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/v1/replay") .WithTags("Replay") .WithOpenApi(); // POST /api/v1/replay - Replay a policy decision group.MapPost("/", ReplayDecisionAsync) .WithName("ReplayDecision") .WithSummary("Replay a historical policy decision") .WithDescription("Re-evaluates a policy decision using frozen snapshots to verify determinism") .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); // POST /api/v1/replay/batch - Batch replay group.MapPost("/batch", BatchReplayAsync) .WithName("BatchReplay") .WithSummary("Replay multiple policy decisions") .WithDescription("Replay a batch of historical policy decisions by verdict hash or Rekor UUID, returning pass/fail and determinism verification results for each item. Used by compliance automation tools to bulk-verify release audit trails.") .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) .Produces(StatusCodes.Status200OK); // GET /api/v1/replay/{replayId} - Get replay result group.MapGet("/{replayId}", GetReplayResultAsync) .WithName("GetReplayResult") .WithSummary("Get the result of a replay operation") .WithDescription("Retrieve the stored result of a previously executed replay operation by its replay ID, including verdict match status, digest comparison, and replay duration metadata.") .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)); // POST /api/v1/replay/verify-determinism - Verify replay determinism group.MapPost("/verify-determinism", VerifyDeterminismAsync) .WithName("VerifyDeterminism") .WithSummary("Verify that a decision can be deterministically replayed") .WithDescription("Execute multiple replay iterations for a verdict hash and report whether all iterations produced the same digest, confirming deterministic reproducibility. Returns the iteration count, number of unique results, and diagnostic details for any non-determinism detected.") .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)); // GET /api/v1/replay/audit - Query replay audit trail group.MapGet("/audit", QueryReplayAuditAsync) .WithName("QueryReplayAudit") .WithSummary("Query replay audit records") .WithDescription("Returns paginated list of replay audit records for compliance and debugging") .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)); // GET /api/v1/replay/audit/metrics - Get replay metrics group.MapGet("/audit/metrics", GetReplayMetricsAsync) .WithName("GetReplayMetrics") .WithSummary("Get aggregated replay metrics") .WithDescription("Returns replay_attempts_total and replay_match_rate metrics") .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)); return endpoints; } private static async Task ReplayDecisionAsync( ReplayRequest request, IReplayEngine replayEngine, IVerifierIdentityProvider verifierIdentity, CancellationToken ct) { if (string.IsNullOrEmpty(request.VerdictHash) && string.IsNullOrEmpty(request.RekorUuid)) { return Results.BadRequest(new ProblemDetails { Title = "Invalid request", Detail = "Either verdictHash or rekorUuid must be provided" }); } var result = await replayEngine.ReplayAsync(new ReplayContext { VerdictHash = request.VerdictHash, RekorUuid = request.RekorUuid, PolicyBundleDigest = request.PolicyBundleDigest, FeedSnapshotDigest = request.FeedSnapshotDigest, ExpectedVerdictDigest = request.ExpectedVerdictDigest, VerifierImageDigest = verifierIdentity.GetImageDigest() }, ct); return Results.Ok(new ReplayResponse { ReplayId = result.ReplayId, Success = result.Success, DeterminismVerified = result.DeterminismVerified, ComputedVerdictDigest = result.ComputedVerdictDigest, ExpectedVerdictDigest = result.ExpectedVerdictDigest, VerdictMatch = result.VerdictMatch, ReplayDuration = result.Duration, Details = result.Details }); } private static async Task BatchReplayAsync( BatchReplayRequest request, IReplayEngine replayEngine, CancellationToken ct) { var results = new List(); foreach (var item in request.Items) { var result = await replayEngine.ReplayAsync(new ReplayContext { VerdictHash = item.VerdictHash, RekorUuid = item.RekorUuid }, ct); results.Add(new ReplayResponse { ReplayId = result.ReplayId, Success = result.Success, DeterminismVerified = result.DeterminismVerified, VerdictMatch = result.VerdictMatch }); } return Results.Ok(new BatchReplayResponse { Total = results.Count, Successful = results.Count(r => r.Success), Failed = results.Count(r => !r.Success), Results = results }); } private static async Task GetReplayResultAsync( string replayId, IReplayAuditStore auditStore, CancellationToken ct) { var result = await auditStore.GetAsync(replayId, ct); if (result == null) { return Results.NotFound(); } return Results.Ok(result); } private static async Task VerifyDeterminismAsync( VerifyDeterminismRequest request, IReplayEngine replayEngine, CancellationToken ct) { var result = await replayEngine.CheckDeterminismAsync( request.VerdictHash, request.NumIterations, ct); return Results.Ok(new VerifyDeterminismResponse { VerdictHash = request.VerdictHash, IsDeterministic = result.IsDeterministic, Iterations = result.Iterations, UniqueResults = result.UniqueResults, Details = result.Details }); } /// /// GET /api/v1/replay/audit /// Query replay audit records. /// Sprint: SPRINT_20260118_019 (GR-007) /// private static async Task QueryReplayAuditAsync( string? bom_ref, string? verdict_hash, string? rekor_uuid, string? from_date, string? to_date, bool? match_only, string? actor, int? limit, string? continuation_token, IReplayAuditRepository auditRepository, HttpContext httpContext, CancellationToken ct) { var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g ? g : Guid.Empty; var query = new ReplayAuditQuery { TenantId = tenantId, BomRef = bom_ref, VerdictHash = verdict_hash, RekorUuid = rekor_uuid, FromDate = string.IsNullOrEmpty(from_date) ? null : DateTimeOffset.Parse(from_date), ToDate = string.IsNullOrEmpty(to_date) ? null : DateTimeOffset.Parse(to_date), MatchOnly = match_only, Actor = actor, Limit = limit ?? 50, ContinuationToken = continuation_token }; var result = await auditRepository.QueryAsync(query, ct); var response = new ReplayAuditResponse { Records = result.Records.Select(r => new ReplayAuditRecordDto { ReplayId = r.ReplayId, BomRef = r.BomRef, VerdictHash = r.VerdictHash, RekorUuid = r.RekorUuid, ReplayedAt = new DateTimeOffset(r.ReplayedAt, TimeSpan.Zero), Match = r.Match, OriginalHash = r.OriginalHash, ReplayedHash = r.ReplayedHash, MismatchReason = r.MismatchReason, PolicyBundleId = r.PolicyBundleId, PolicyBundleHash = r.PolicyBundleHash, VerifierDigest = r.VerifierDigest, DurationMs = r.DurationMs, Actor = r.Actor, Source = r.Source }).ToList(), Total = result.Total, ContinuationToken = result.ContinuationToken }; return Results.Ok(response); } /// /// GET /api/v1/replay/audit/metrics /// Get aggregated replay metrics. /// Sprint: SPRINT_20260118_019 (GR-007) /// private static async Task GetReplayMetricsAsync( string? from_date, string? to_date, IReplayAuditRepository auditRepository, HttpContext httpContext, CancellationToken ct) { var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g ? g : Guid.Empty; var fromDate = string.IsNullOrEmpty(from_date) ? null : (DateTimeOffset?)DateTimeOffset.Parse(from_date); var toDate = string.IsNullOrEmpty(to_date) ? null : (DateTimeOffset?)DateTimeOffset.Parse(to_date); var metrics = await auditRepository.GetMetricsAsync(tenantId, fromDate, toDate, ct); return Results.Ok(new ReplayMetricsResponse { TotalAttempts = metrics.TotalAttempts, SuccessfulMatches = metrics.SuccessfulMatches, Mismatches = metrics.Mismatches, MatchRate = metrics.MatchRate, AverageDurationMs = metrics.AverageDurationMs }); } } /// /// Request to replay a policy decision. /// public sealed record ReplayRequest { /// Verdict hash to replay. public string? VerdictHash { get; init; } /// Rekor UUID to replay. public string? RekorUuid { get; init; } /// Policy bundle digest to use (optional, uses recorded if null). public string? PolicyBundleDigest { get; init; } /// Feed snapshot digest to use (optional, uses recorded if null). public string? FeedSnapshotDigest { get; init; } /// Expected verdict digest for verification. public string? ExpectedVerdictDigest { get; init; } } /// /// Response from replay operation. /// public sealed record ReplayResponse { /// Unique replay ID. public required string ReplayId { get; init; } /// Whether replay succeeded. public required bool Success { get; init; } /// Whether determinism was verified. public bool DeterminismVerified { get; init; } /// Computed verdict digest from replay. public string? ComputedVerdictDigest { get; init; } /// Expected verdict digest. public string? ExpectedVerdictDigest { get; init; } /// Whether computed matches expected. public bool VerdictMatch { get; init; } /// Replay duration. public TimeSpan ReplayDuration { get; init; } /// Additional details. public string? Details { get; init; } } /// /// Batch replay request. /// public sealed record BatchReplayRequest { /// Items to replay. public required IReadOnlyList Items { get; init; } } /// /// Batch replay response. /// public sealed record BatchReplayResponse { /// Total items processed. public int Total { get; init; } /// Successful replays. public int Successful { get; init; } /// Failed replays. public int Failed { get; init; } /// Individual results. public required IReadOnlyList Results { get; init; } } /// /// Request to verify determinism. /// public sealed record VerifyDeterminismRequest { /// Verdict hash to verify. public required string VerdictHash { get; init; } /// Number of replay iterations. public int NumIterations { get; init; } = 3; } /// /// Response from determinism verification. /// public sealed record VerifyDeterminismResponse { /// Verdict hash verified. public required string VerdictHash { get; init; } /// Whether all iterations produced same result. public required bool IsDeterministic { get; init; } /// Number of iterations run. public int Iterations { get; init; } /// Number of unique results (should be 1 if deterministic). public int UniqueResults { get; init; } /// Details about any non-determinism. public string? Details { get; init; } } // Interfaces /// /// Replay engine interface. /// public interface IReplayEngine { /// Replays a policy decision. Task ReplayAsync(ReplayContext context, CancellationToken ct = default); /// Verifies determinism by running multiple iterations. Task CheckDeterminismAsync(string verdictHash, int iterations, CancellationToken ct = default); } /// /// Verifier identity provider. /// public interface IVerifierIdentityProvider { /// Gets the container image digest of the verifier. string? GetImageDigest(); /// Gets the verifier version. string GetVersion(); } /// /// Replay audit store. /// public interface IReplayAuditStore { /// Gets a replay result by ID. Task GetAsync(string replayId, CancellationToken ct = default); /// Stores a replay result. Task StoreAsync(ReplayResponse result, CancellationToken ct = default); } /// /// Replay context. /// public sealed record ReplayContext { /// Verdict hash. public string? VerdictHash { get; init; } /// Rekor UUID. public string? RekorUuid { get; init; } /// Policy bundle digest. public string? PolicyBundleDigest { get; init; } /// Feed snapshot digest. public string? FeedSnapshotDigest { get; init; } /// Expected verdict digest. public string? ExpectedVerdictDigest { get; init; } /// Verifier image digest. public string? VerifierImageDigest { get; init; } } /// /// Replay result. /// public sealed record ReplayResult { /// Replay ID. public required string ReplayId { get; init; } /// Success. public required bool Success { get; init; } /// Determinism verified. public bool DeterminismVerified { get; init; } /// Computed verdict digest. public string? ComputedVerdictDigest { get; init; } /// Expected verdict digest. public string? ExpectedVerdictDigest { get; init; } /// Verdict match. public bool VerdictMatch { get; init; } /// Duration. public TimeSpan Duration { get; init; } /// Details. public string? Details { get; init; } } /// /// Determinism check result. /// public sealed record DeterminismCheckResult { /// Is deterministic. public required bool IsDeterministic { get; init; } /// Iterations run. public int Iterations { get; init; } /// Unique results count. public int UniqueResults { get; init; } /// Details. public string? Details { get; init; } } /// /// Problem details for error responses. /// public sealed record ProblemDetails { /// Error title. public string? Title { get; init; } /// Error detail. public string? Detail { get; init; } /// Error type. public string? Type { get; init; } /// HTTP status. public int Status { get; init; } } #region Replay Audit DTOs (GR-007) /// /// Response for replay audit query. /// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure /// Task: GR-007 - Create replay audit trail /// public sealed record ReplayAuditResponse { /// List of replay audit records. [JsonPropertyName("records")] public List Records { get; init; } = []; /// Total count of matching records. [JsonPropertyName("total")] public long Total { get; init; } /// Token for fetching next page. [JsonPropertyName("continuation_token")] public string? ContinuationToken { get; init; } } /// /// Replay audit record DTO for API response. /// public sealed record ReplayAuditRecordDto { /// Unique replay ID. [JsonPropertyName("replay_id")] public Guid ReplayId { get; init; } /// BOM reference. [JsonPropertyName("bom_ref")] public required string BomRef { get; init; } /// Verdict hash that was replayed. [JsonPropertyName("verdict_hash")] public required string VerdictHash { get; init; } /// Rekor transparency log UUID. [JsonPropertyName("rekor_uuid")] public string? RekorUuid { get; init; } /// When the replay was executed. [JsonPropertyName("replayed_at")] public DateTimeOffset ReplayedAt { get; init; } /// Whether replayed verdict matched original. [JsonPropertyName("match")] public bool Match { get; init; } /// Original verdict hash. [JsonPropertyName("original_hash")] public string? OriginalHash { get; init; } /// Computed verdict hash from replay. [JsonPropertyName("replayed_hash")] public string? ReplayedHash { get; init; } /// Reason for mismatch if match=false. [JsonPropertyName("mismatch_reason")] public string? MismatchReason { get; init; } /// Policy bundle identifier used. [JsonPropertyName("policy_bundle_id")] public string? PolicyBundleId { get; init; } /// Policy bundle content hash. [JsonPropertyName("policy_bundle_hash")] public string? PolicyBundleHash { get; init; } /// Verifier service image digest. [JsonPropertyName("verifier_digest")] public string? VerifierDigest { get; init; } /// Replay duration in milliseconds. [JsonPropertyName("duration_ms")] public int? DurationMs { get; init; } /// Actor who triggered the replay. [JsonPropertyName("actor")] public string? Actor { get; init; } /// Source of replay request (api, cli, scheduled). [JsonPropertyName("source")] public string? Source { get; init; } } /// /// Aggregated metrics for replay operations. /// public sealed record ReplayMetricsResponse { /// Total replay attempts. [JsonPropertyName("total_attempts")] public long TotalAttempts { get; init; } /// Successful matches. [JsonPropertyName("successful_matches")] public long SuccessfulMatches { get; init; } /// Mismatches. [JsonPropertyName("mismatches")] public long Mismatches { get; init; } /// Match rate (0.0-1.0). [JsonPropertyName("match_rate")] public double MatchRate { get; init; } /// Average duration in milliseconds. [JsonPropertyName("average_duration_ms")] public double? AverageDurationMs { get; init; } } #endregion