// -----------------------------------------------------------------------------
// 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