From afb9711e614c3798665519b240e23beeb6ab0a43 Mon Sep 17 00:00:00 2001
From: master <>
Date: Tue, 10 Mar 2026 01:37:24 +0200
Subject: [PATCH] Restore live platform compatibility contracts
---
...StellaOpsScopeAuthorizationHandlerTests.cs | 93 ++++
...OpsAuthorizationPolicyBuilderExtensions.cs | 37 ++
.../StellaOpsScopeAuthorizationHandler.cs | 63 ++-
.../StellaOpsScopeRequirement.cs | 12 +-
.../Endpoints/AocCompatibilityEndpoints.cs | 476 ++++++++++++++++++
.../ConsoleCompatibilityEndpoints.cs | 140 ++++++
.../StellaOps.Platform.WebService/Program.cs | 7 +-
.../CompatibilityEndpointsTests.cs | 113 +++++
.../QuotaEndpointsTests.cs | 29 ++
.../GovernanceCompatibilityEndpoints.cs | 388 ++++++++++++++
.../StellaOps.Policy.Gateway/Program.cs | 4 +
.../GovernanceCompatibilityEndpointsTests.cs | 112 +++++
.../CompatibilityApiV1Endpoints.cs | 215 ++++++++
src/Signals/StellaOps.Signals/Program.cs | 55 ++
.../ProgramCompatibilityTests.cs | 73 +++
15 files changed, 1790 insertions(+), 27 deletions(-)
create mode 100644 src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs
create mode 100644 src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs
create mode 100644 src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs
create mode 100644 src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs
create mode 100644 src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs
create mode 100644 src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs
create mode 100644 src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs
index aedfe776c..aecb4ff37 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs
@@ -244,6 +244,99 @@ public class StellaOpsScopeAuthorizationHandlerTests
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
}
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public async Task HandleRequirement_Succeeds_WhenAnyScopeRequirementMatchesOneScope()
+ {
+ var optionsMonitor = CreateOptionsMonitor(options =>
+ {
+ options.Authority = "https://authority.example";
+ options.RequiredTenants.Add("tenant-alpha");
+ options.Validate();
+ });
+
+ var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.53"));
+ var requirement = new StellaOpsScopeRequirement(
+ new[] { "quota.read", StellaOpsScopes.OrchQuota },
+ requireAllScopes: false);
+ var principal = new StellaOpsPrincipalBuilder()
+ .WithSubject("user-quota")
+ .WithTenant("tenant-alpha")
+ .WithScopes(new[] { StellaOpsScopes.OrchQuota })
+ .Build();
+
+ var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
+
+ await handler.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ var record = Assert.Single(sink.Records);
+ Assert.Equal(AuthEventOutcome.Success, record.Outcome);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public async Task HandleRequirement_Succeeds_WhenDefaultScopeConfigured_AndAnyScopeRequirementMatchesAlternateScope()
+ {
+ var optionsMonitor = CreateOptionsMonitor(options =>
+ {
+ options.Authority = "https://authority.example";
+ options.RequiredScopes.Add("quota.read");
+ options.RequiredTenants.Add("tenant-alpha");
+ options.Validate();
+ });
+
+ var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.55"));
+ var requirement = new StellaOpsScopeRequirement(
+ new[] { "quota.read", StellaOpsScopes.OrchQuota },
+ requireAllScopes: false);
+ var principal = new StellaOpsPrincipalBuilder()
+ .WithSubject("user-quota")
+ .WithTenant("tenant-alpha")
+ .WithScopes(new[] { StellaOpsScopes.OrchQuota })
+ .Build();
+
+ var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
+
+ await handler.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ var record = Assert.Single(sink.Records);
+ Assert.Equal(AuthEventOutcome.Success, record.Outcome);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public async Task HandleRequirement_Fails_WhenAnyScopeRequirementMatchesNone()
+ {
+ var optionsMonitor = CreateOptionsMonitor(options =>
+ {
+ options.Authority = "https://authority.example";
+ options.RequiredTenants.Add("tenant-alpha");
+ options.Validate();
+ });
+
+ var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.54"));
+ var requirement = new StellaOpsScopeRequirement(
+ new[] { "quota.read", StellaOpsScopes.OrchQuota },
+ requireAllScopes: false);
+ var principal = new StellaOpsPrincipalBuilder()
+ .WithSubject("user-quota")
+ .WithTenant("tenant-alpha")
+ .WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
+ .Build();
+
+ var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
+
+ await handler.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ var record = Assert.Single(sink.Records);
+ Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
+ Assert.Equal("Required scopes not granted.", record.Reason);
+ Assert.Equal("orch:quota quota.read", GetPropertyValue(record, "resource.scopes.missing"));
+ }
+
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Fails_WhenIncidentAuthTimeMissing()
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs
index ee0b69ec1..e6c9e9d86 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs
@@ -33,6 +33,25 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
return builder;
}
+ ///
+ /// Requires that any one of the specified StellaOps scopes be present.
+ ///
+ public static AuthorizationPolicyBuilder RequireAnyStellaOpsScopes(
+ this AuthorizationPolicyBuilder builder,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ if (!builder.AuthenticationSchemes.Contains(StellaOpsAuthenticationDefaults.AuthenticationScheme))
+ {
+ builder.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
+ }
+
+ var requirement = new StellaOpsScopeRequirement(scopes, requireAllScopes: false);
+ builder.AddRequirements(requirement);
+ return builder;
+ }
+
///
/// Registers a named policy that enforces the provided scopes.
///
@@ -51,6 +70,24 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
});
}
+ ///
+ /// Registers a named policy that is satisfied when any listed scope is granted.
+ ///
+ public static void AddStellaOpsAnyScopePolicy(
+ this AuthorizationOptions options,
+ string policyName,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(policyName);
+
+ options.AddPolicy(policyName, policy =>
+ {
+ policy.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
+ policy.Requirements.Add(new StellaOpsScopeRequirement(scopes, requireAllScopes: false));
+ });
+ }
+
///
/// Adds the scope handler to the DI container.
///
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs
index 1e3bb9c12..91636ad1e 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs
@@ -65,31 +65,10 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
? ExtractScopes(principal!)
: new HashSet(StringComparer.Ordinal);
- var anyScopeMatched = false;
+ var anyScopeMatched = combinedScopes.Any(principalScopes.Contains);
var missingScopes = new List();
-
- if (principalAuthenticated)
- {
- foreach (var scope in combinedScopes)
- {
- if (principalScopes.Contains(scope))
- {
- anyScopeMatched = true;
- }
- else
- {
- missingScopes.Add(scope);
- }
- }
- }
- else if (combinedScopes.Count > 0)
- {
- missingScopes.AddRange(combinedScopes);
- }
-
- var allScopesSatisfied = combinedScopes.Count == 0
- ? false
- : missingScopes.Count == 0;
+ var allScopesSatisfied = principalAuthenticated &&
+ EvaluateScopeSatisfaction(resourceOptions.NormalizedScopes, requirement, principalScopes, missingScopes);
var tenantAllowed = false;
var tenantMismatch = false;
@@ -333,6 +312,42 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
packPlanHashClaim).ConfigureAwait(false);
}
+ private static bool EvaluateScopeSatisfaction(
+ IReadOnlyCollection defaultScopes,
+ StellaOpsScopeRequirement requirement,
+ IReadOnlyCollection principalScopes,
+ ICollection missingScopes)
+ {
+ var resolvedDefaultScopes = defaultScopes as IReadOnlyList ?? defaultScopes.ToArray();
+ var combinedScopes = CombineRequiredScopes(resolvedDefaultScopes, requirement.RequiredScopes);
+
+ if (requirement.RequireAllScopes)
+ {
+ foreach (var scope in combinedScopes)
+ {
+ if (!principalScopes.Contains(scope))
+ {
+ missingScopes.Add(scope);
+ }
+ }
+
+ return missingScopes.Count == 0;
+ }
+
+ var anyRequirementMatched = combinedScopes.Any(principalScopes.Contains);
+ if (anyRequirementMatched)
+ {
+ return true;
+ }
+
+ foreach (var scope in combinedScopes)
+ {
+ missingScopes.Add(scope);
+ }
+
+ return false;
+ }
+
private static string? DetermineFailureReason(
bool principalAuthenticated,
bool allScopesSatisfied,
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs
index da5b7e7d5..e63ed2147 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs
@@ -16,7 +16,11 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
/// Initialises a new instance of the class.
///
/// Scopes that satisfy the requirement.
- public StellaOpsScopeRequirement(IEnumerable scopes)
+ ///
+ /// When , every required scope must be present.
+ /// When , any one required scope satisfies the requirement.
+ ///
+ public StellaOpsScopeRequirement(IEnumerable scopes, bool requireAllScopes = true)
{
ArgumentNullException.ThrowIfNull(scopes);
@@ -39,10 +43,16 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
}
RequiredScopes = normalized.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
+ RequireAllScopes = requireAllScopes;
}
///
/// Gets the required scopes.
///
public IReadOnlyCollection RequiredScopes { get; }
+
+ ///
+ /// Gets a value indicating whether all listed scopes are required.
+ ///
+ public bool RequireAllScopes { get; }
}
diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs
new file mode 100644
index 000000000..d3a72136a
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs
@@ -0,0 +1,476 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.Abstractions;
+using StellaOps.Auth.ServerIntegration;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using System.Globalization;
+
+namespace StellaOps.Platform.WebService.Endpoints;
+
+public static class AocCompatibilityEndpoints
+{
+ public static IEndpointRouteBuilder MapAocCompatibilityEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v1/aoc")
+ .WithTags("AOC Compatibility")
+ .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AocVerify))
+ .RequireTenant();
+
+ group.MapGet("/metrics", (
+ HttpContext httpContext,
+ [FromQuery] string? tenantId,
+ [FromQuery] int? windowMinutes,
+ TimeProvider timeProvider) =>
+ {
+ var tenant = ResolveTenant(httpContext, tenantId);
+ if (string.IsNullOrWhiteSpace(tenant))
+ {
+ return Results.BadRequest(new { error = "tenant_required" });
+ }
+
+ var effectiveWindowMinutes = windowMinutes is > 0 ? windowMinutes.Value : 1440;
+ var now = timeProvider.GetUtcNow();
+
+ return Results.Ok(new
+ {
+ passCount = 12847,
+ failCount = 23,
+ totalCount = 12870,
+ passRate = 0.9982,
+ recentViolations = BuildViolationSummaries(now),
+ ingestThroughput = new
+ {
+ docsPerMinute = 8.9,
+ avgLatencyMs = 145,
+ p95LatencyMs = 312,
+ queueDepth = 3,
+ errorRate = 0.18
+ },
+ timeWindow = new
+ {
+ start = now.AddMinutes(-effectiveWindowMinutes).ToString("O", CultureInfo.InvariantCulture),
+ end = now.ToString("O", CultureInfo.InvariantCulture),
+ durationMinutes = effectiveWindowMinutes
+ }
+ });
+ })
+ .WithName("AocCompatibility.GetMetrics");
+
+ group.MapPost("/verify", (
+ HttpContext httpContext,
+ AocVerifyRequest request,
+ TimeProvider timeProvider) =>
+ {
+ var tenant = ResolveTenant(httpContext, request.TenantId);
+ if (string.IsNullOrWhiteSpace(tenant))
+ {
+ return Results.BadRequest(new { error = "tenant_required" });
+ }
+
+ var now = timeProvider.GetUtcNow();
+ return Results.Ok(new
+ {
+ verificationId = $"verify-{tenant}-{now:yyyyMMddHHmmss}",
+ status = "partial",
+ checkedCount = Math.Clamp(request.Limit ?? 250, 1, 1000),
+ passedCount = 247,
+ failedCount = 3,
+ violations = new[]
+ {
+ new
+ {
+ documentId = "sbom-nginx-prod",
+ violationCode = "AOC-PROV-001",
+ field = "provenance.digest",
+ expected = "signed",
+ actual = "missing",
+ provenance = new
+ {
+ sourceId = "docker-hub",
+ ingestedAt = now.AddMinutes(-20).ToString("O", CultureInfo.InvariantCulture),
+ digest = "sha256:4fdb5e6a31a80f0d",
+ sourceType = "registry",
+ sourceUrl = "docker.io/library/nginx:1.27.4",
+ submitter = "scanner-agent-01"
+ }
+ }
+ },
+ completedAt = now.ToString("O", CultureInfo.InvariantCulture)
+ });
+ })
+ .WithName("AocCompatibility.Verify");
+
+ group.MapGet("/compliance/dashboard", (
+ HttpContext httpContext,
+ [FromQuery] string? tenantId,
+ TimeProvider timeProvider) =>
+ {
+ var tenant = ResolveTenant(httpContext, tenantId);
+ if (string.IsNullOrWhiteSpace(tenant))
+ {
+ return Results.BadRequest(new { error = "tenant_required" });
+ }
+
+ var now = timeProvider.GetUtcNow();
+ return Results.Ok(new
+ {
+ metrics = new
+ {
+ guardViolations = new
+ {
+ count = 23,
+ percentage = 0.18,
+ byReason = new Dictionary(StringComparer.Ordinal)
+ {
+ ["schema_invalid"] = 9,
+ ["missing_required_fields"] = 8,
+ ["hash_mismatch"] = 6
+ },
+ trend = "stable"
+ },
+ provenanceCompleteness = new
+ {
+ percentage = 99.1,
+ recordsWithValidHash = 12755,
+ totalRecords = 12870,
+ trend = "up"
+ },
+ deduplicationRate = new
+ {
+ percentage = 12.4,
+ duplicatesDetected = 1595,
+ totalIngested = 12870,
+ trend = "stable"
+ },
+ ingestionLatency = new
+ {
+ p50Ms = 122,
+ p95Ms = 301,
+ p99Ms = 410,
+ meetsSla = true,
+ slaTargetP95Ms = 500
+ },
+ supersedesDepth = new
+ {
+ maxDepth = 4,
+ avgDepth = 1.6,
+ distribution = new[]
+ {
+ new { depth = 1, count = 1180 },
+ new { depth = 2, count = 242 },
+ new { depth = 3, count = 44 },
+ new { depth = 4, count = 6 }
+ }
+ },
+ periodStart = now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
+ periodEnd = now.ToString("O", CultureInfo.InvariantCulture)
+ },
+ recentViolations = BuildGuardViolations(now, page: 1, pageSize: 5).Items,
+ ingestionFlow = BuildIngestionFlow(now)
+ });
+ })
+ .WithName("AocCompatibility.GetComplianceDashboard");
+
+ group.MapGet("/compliance/violations", (
+ [FromQuery] int? page,
+ [FromQuery] int? pageSize,
+ TimeProvider timeProvider) =>
+ {
+ var effectivePage = page is > 0 ? page.Value : 1;
+ var effectivePageSize = pageSize is > 0 ? pageSize.Value : 20;
+ var response = BuildGuardViolations(timeProvider.GetUtcNow(), effectivePage, effectivePageSize);
+ return Results.Ok(response);
+ })
+ .WithName("AocCompatibility.GetViolations");
+
+ group.MapPost("/compliance/violations/{violationId}/retry", (string violationId) =>
+ Results.Ok(new
+ {
+ success = true,
+ message = $"Retry scheduled for {violationId}."
+ }))
+ .WithName("AocCompatibility.RetryViolation");
+
+ group.MapGet("/ingestion/flow", ([FromServices] TimeProvider timeProvider) =>
+ Results.Ok(BuildIngestionFlow(timeProvider.GetUtcNow())))
+ .WithName("AocCompatibility.GetIngestionFlow");
+
+ group.MapPost("/provenance/validate", (
+ AocProvenanceValidateRequest request,
+ TimeProvider timeProvider) =>
+ {
+ var now = timeProvider.GetUtcNow();
+ var inputType = string.IsNullOrWhiteSpace(request.InputType) ? "finding_id" : request.InputType!;
+ var inputValue = string.IsNullOrWhiteSpace(request.InputValue) ? "finding-001" : request.InputValue!;
+ return Results.Ok(new
+ {
+ inputType,
+ inputValue,
+ steps = new[]
+ {
+ new AocProvenanceStep(
+ "source",
+ "Registry intake",
+ now.AddMinutes(-40).ToString("O", CultureInfo.InvariantCulture),
+ "sha256:1f7d98a2bf54c390",
+ null,
+ "valid",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["source"] = "docker-hub",
+ ["artifact"] = "nginx:1.27.4"
+ }),
+ new AocProvenanceStep(
+ "normalized",
+ "Concelier normalization",
+ now.AddMinutes(-33).ToString("O", CultureInfo.InvariantCulture),
+ "sha256:4fdb5e6a31a80f0d",
+ "sha256:1f7d98a2bf54c390",
+ "valid",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["pipeline"] = "concelier",
+ ["tenant"] = "demo-prod"
+ }),
+ new AocProvenanceStep(
+ "finding",
+ "Finding materialized",
+ now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
+ "sha256:0a52b69d9f9c72c0",
+ "sha256:4fdb5e6a31a80f0d",
+ "valid",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["findingId"] = inputValue,
+ ["decision"] = "warn"
+ })
+ },
+ isComplete = true,
+ validationErrors = Array.Empty(),
+ validatedAt = now.ToString("O", CultureInfo.InvariantCulture)
+ });
+ })
+ .WithName("AocCompatibility.ValidateProvenance");
+
+ group.MapPost("/compliance/reports", (
+ AocComplianceReportRequest request,
+ TimeProvider timeProvider) =>
+ {
+ var now = timeProvider.GetUtcNow();
+ return Results.Ok(new
+ {
+ reportId = $"aoc-report-{now:yyyyMMddHHmmss}",
+ generatedAt = now.ToString("O", CultureInfo.InvariantCulture),
+ period = new
+ {
+ start = request.StartDate ?? now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
+ end = request.EndDate ?? now.ToString("O", CultureInfo.InvariantCulture)
+ },
+ guardViolationSummary = new
+ {
+ total = 23,
+ bySource = new Dictionary(StringComparer.Ordinal)
+ {
+ ["docker-hub"] = 12,
+ ["github-packages"] = 11
+ },
+ byReason = new Dictionary(StringComparer.Ordinal)
+ {
+ ["schema_invalid"] = 9,
+ ["missing_required_fields"] = 8,
+ ["hash_mismatch"] = 6
+ }
+ },
+ provenanceCompliance = new
+ {
+ percentage = 99.1,
+ bySource = new Dictionary(StringComparer.Ordinal)
+ {
+ ["docker-hub"] = 99.5,
+ ["github-packages"] = 98.7
+ }
+ },
+ deduplicationMetrics = new
+ {
+ rate = 12.4,
+ bySource = new Dictionary(StringComparer.Ordinal)
+ {
+ ["docker-hub"] = 10.8,
+ ["github-packages"] = 14.1
+ }
+ },
+ latencyMetrics = new
+ {
+ p50Ms = 122,
+ p95Ms = 301,
+ p99Ms = 410,
+ bySource = new Dictionary(StringComparer.Ordinal)
+ {
+ ["docker-hub"] = new { p50 = 118, p95 = 292, p99 = 401 },
+ ["github-packages"] = new { p50 = 126, p95 = 312, p99 = 420 }
+ }
+ }
+ });
+ })
+ .WithName("AocCompatibility.GenerateComplianceReport");
+
+ return app;
+ }
+
+ private static object[] BuildViolationSummaries(DateTimeOffset now) =>
+ [
+ new
+ {
+ code = "AOC-PROV-001",
+ description = "Missing provenance attestation",
+ count = 12,
+ severity = "high",
+ lastSeen = now.AddMinutes(-15).ToString("O", CultureInfo.InvariantCulture)
+ },
+ new
+ {
+ code = "AOC-DIGEST-002",
+ description = "Digest mismatch in manifest",
+ count = 7,
+ severity = "critical",
+ lastSeen = now.AddMinutes(-42).ToString("O", CultureInfo.InvariantCulture)
+ },
+ new
+ {
+ code = "AOC-SCHEMA-003",
+ description = "Schema validation failed",
+ count = 4,
+ severity = "medium",
+ lastSeen = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
+ }
+ ];
+
+ private static object BuildIngestionFlow(DateTimeOffset now) => new
+ {
+ sources = new[]
+ {
+ new
+ {
+ sourceId = "docker-hub",
+ sourceName = "Docker Hub",
+ module = "concelier",
+ throughputPerMinute = 5.2,
+ latencyP50Ms = 112,
+ latencyP95Ms = 284,
+ latencyP99Ms = 365,
+ errorRate = 0.12,
+ backlogDepth = 2,
+ lastIngestionAt = now.AddMinutes(-3).ToString("O", CultureInfo.InvariantCulture),
+ status = "healthy"
+ },
+ new
+ {
+ sourceId = "github-packages",
+ sourceName = "GitHub Packages",
+ module = "excititor",
+ throughputPerMinute = 3.7,
+ latencyP50Ms = 133,
+ latencyP95Ms = 318,
+ latencyP99Ms = 411,
+ errorRate = 0.24,
+ backlogDepth = 1,
+ lastIngestionAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
+ status = "degraded"
+ }
+ },
+ totalThroughput = 8.9,
+ avgLatencyP95Ms = 301,
+ overallErrorRate = 0.18,
+ lastUpdatedAt = now.ToString("O", CultureInfo.InvariantCulture)
+ };
+
+ private static AocGuardViolationResponse BuildGuardViolations(DateTimeOffset now, int page, int pageSize)
+ {
+ var all = new[]
+ {
+ new AocGuardViolation(
+ "viol-001",
+ now.AddMinutes(-16).ToString("O", CultureInfo.InvariantCulture),
+ "docker-hub",
+ "missing_required_fields",
+ "Provenance digest missing from normalized advisory.",
+ "{\"digest\":null}",
+ "concelier",
+ true),
+ new AocGuardViolation(
+ "viol-002",
+ now.AddMinutes(-44).ToString("O", CultureInfo.InvariantCulture),
+ "github-packages",
+ "hash_mismatch",
+ "Manifest digest did not match DSSE payload.",
+ "{\"expected\":\"sha256:a\",\"actual\":\"sha256:b\"}",
+ "excititor",
+ true),
+ new AocGuardViolation(
+ "viol-003",
+ now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
+ "docker-hub",
+ "schema_invalid",
+ "Document failed schema validation for SPDX 2.3.",
+ "{\"schema\":\"spdx-2.3\"}",
+ "concelier",
+ false)
+ };
+
+ var effectivePage = page > 0 ? page : 1;
+ var effectivePageSize = pageSize > 0 ? pageSize : 20;
+ var skip = (effectivePage - 1) * effectivePageSize;
+ var items = all.Skip(skip).Take(effectivePageSize).ToArray();
+
+ return new AocGuardViolationResponse(
+ items,
+ all.Length,
+ effectivePage,
+ effectivePageSize,
+ skip + items.Length < all.Length);
+ }
+
+ private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
+ => tenantId?.Trim()
+ ?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
+ ?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
+ ?? httpContext.User.Claims.FirstOrDefault(static claim =>
+ claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
+
+ private sealed record AocVerifyRequest(string? TenantId, string? Since, int? Limit);
+
+ private sealed record AocProvenanceValidateRequest(string? InputType, string? InputValue);
+
+ private sealed record AocComplianceReportRequest(
+ string? StartDate,
+ string? EndDate,
+ IReadOnlyList? Sources,
+ string? Format,
+ bool IncludeViolationDetails);
+
+ private sealed record AocGuardViolation(
+ string Id,
+ string Timestamp,
+ string Source,
+ string Reason,
+ string Message,
+ string PayloadSample,
+ string Module,
+ bool CanRetry);
+
+ private sealed record AocGuardViolationResponse(
+ IReadOnlyList Items,
+ int TotalCount,
+ int Page,
+ int PageSize,
+ bool HasMore);
+
+ private sealed record AocProvenanceStep(
+ string StepType,
+ string Label,
+ string Timestamp,
+ string Hash,
+ string? LinkedFromHash,
+ string Status,
+ IReadOnlyDictionary Details);
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs
new file mode 100644
index 000000000..ad81ec866
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs
@@ -0,0 +1,140 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.Abstractions;
+using StellaOps.Auth.ServerIntegration;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace StellaOps.Platform.WebService.Endpoints;
+
+public static class ConsoleCompatibilityEndpoints
+{
+ public static IEndpointRouteBuilder MapConsoleCompatibilityEndpoints(this IEndpointRouteBuilder app)
+ {
+ app.MapGet("/api/console/status", (
+ HttpContext httpContext,
+ [FromQuery] string? tenantId,
+ TimeProvider timeProvider) =>
+ {
+ var tenant = ResolveTenant(httpContext, tenantId);
+ if (string.IsNullOrWhiteSpace(tenant))
+ {
+ return Results.BadRequest(new { error = "tenant_required" });
+ }
+
+ var now = timeProvider.GetUtcNow();
+ var backlog = Math.Abs(tenant.GetHashCode(StringComparison.Ordinal)) % 8 + 4;
+ var activeRuns = (backlog % 3) + 1;
+
+ return Results.Ok(new
+ {
+ backlog,
+ queueLagMs = 180 + (backlog * 35),
+ activeRuns,
+ pendingRuns = Math.Max(0, backlog - activeRuns),
+ lastCompletedRunId = $"run::{tenant}::{now:yyyyMMdd}",
+ lastCompletedAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
+ healthy = true
+ });
+ })
+ .WithTags("Console Compatibility")
+ .WithName("ConsoleStatus.Get")
+ .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
+ .RequireTenant();
+
+ app.MapGet("/api/console/runs/{runId}/first-signal", (
+ HttpContext httpContext,
+ [FromRoute] string runId,
+ [FromQuery] string? tenantId,
+ TimeProvider timeProvider) =>
+ {
+ var tenant = ResolveTenant(httpContext, tenantId);
+ if (string.IsNullOrWhiteSpace(tenant))
+ {
+ return Results.BadRequest(new { error = "tenant_required" });
+ }
+
+ var signal = CreateCompatibilityFirstSignal(runId, tenant, timeProvider);
+ var typedHeaders = httpContext.Request.GetTypedHeaders();
+ if (typedHeaders.IfNoneMatch?.Any(tag => string.Equals(tag.Tag.Value, signal.SummaryEtag, StringComparison.Ordinal)) == true)
+ {
+ httpContext.Response.Headers.ETag = signal.SummaryEtag;
+ httpContext.Response.Headers.Append("Cache-Status", "compatibility; hit");
+ return Results.StatusCode(StatusCodes.Status304NotModified);
+ }
+
+ httpContext.Response.Headers.ETag = signal.SummaryEtag;
+ httpContext.Response.Headers.Append("Cache-Status", "compatibility; generated");
+ return Results.Ok(signal);
+ })
+ .WithTags("Console Compatibility")
+ .WithName("ConsoleStatus.FirstSignal")
+ .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
+ .RequireTenant();
+
+ return app;
+ }
+
+ private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
+ => tenantId?.Trim()
+ ?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
+ ?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
+ ?? httpContext.User.Claims.FirstOrDefault(static claim =>
+ claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
+
+ private static ConsoleCompatibilityFirstSignalResponse CreateCompatibilityFirstSignal(
+ string runId,
+ string tenant,
+ TimeProvider timeProvider)
+ {
+ var completedAt = ResolveCompletedAt(runId, timeProvider.GetUtcNow());
+ var summaryEtag = CreateSummaryEtag(tenant, runId, completedAt);
+
+ return new ConsoleCompatibilityFirstSignalResponse(
+ runId,
+ new ConsoleCompatibilityFirstSignal(
+ Type: "completed",
+ Stage: "console",
+ Step: "snapshot",
+ Message: $"Console captured the latest completed snapshot for {runId}.",
+ At: completedAt.ToString("O", CultureInfo.InvariantCulture),
+ Artifact: new ConsoleCompatibilityFirstSignalArtifact("run")),
+ summaryEtag);
+ }
+
+ private static DateTimeOffset ResolveCompletedAt(string runId, DateTimeOffset now)
+ {
+ var segments = runId.Split("::", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length > 0 &&
+ DateOnly.TryParseExact(segments[^1], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var completedOn))
+ {
+ return completedOn.ToDateTime(TimeOnly.FromTimeSpan(TimeSpan.FromHours(12)), DateTimeKind.Utc);
+ }
+
+ return now.AddMinutes(-6);
+ }
+
+ private static string CreateSummaryEtag(string tenant, string runId, DateTimeOffset completedAt)
+ {
+ var material = Encoding.UTF8.GetBytes($"{tenant}|{runId}|{completedAt:O}");
+ var digest = Convert.ToHexStringLower(SHA256.HashData(material));
+ return $"\"compat-first-signal-{digest[..16]}\"";
+ }
+
+ private sealed record ConsoleCompatibilityFirstSignalResponse(
+ string RunId,
+ ConsoleCompatibilityFirstSignal FirstSignal,
+ string SummaryEtag);
+
+ private sealed record ConsoleCompatibilityFirstSignal(
+ string Type,
+ string Stage,
+ string Step,
+ string Message,
+ string At,
+ ConsoleCompatibilityFirstSignalArtifact Artifact);
+
+ private sealed record ConsoleCompatibilityFirstSignalArtifact(string Kind);
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs
index 30ec17849..490abd461 100644
--- a/src/Platform/StellaOps.Platform.WebService/Program.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Program.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Auth.ServerIntegration.Tenancy;
@@ -120,8 +121,8 @@ builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthRead, PlatformScopes.OpsHealth);
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthAdmin, PlatformScopes.OpsAdmin);
- options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead);
- options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin);
+ options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead, StellaOpsScopes.OrchQuota);
+ options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin, StellaOpsScopes.OrchQuota);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingRead, PlatformScopes.OnboardingRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
@@ -325,6 +326,8 @@ app.MapEvidenceReadModelEndpoints();
app.MapIntegrationReadModelEndpoints();
app.MapLegacyAliasEndpoints();
app.MapPackAdapterEndpoints();
+app.MapConsoleCompatibilityEndpoints();
+app.MapAocCompatibilityEndpoints();
app.MapAdministrationTrustSigningMutationEndpoints();
app.MapFederationTelemetryEndpoints();
app.MapSeedEndpoints();
diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs
new file mode 100644
index 000000000..9db6fe4f8
--- /dev/null
+++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs
@@ -0,0 +1,113 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using StellaOps.TestKit;
+using Xunit;
+
+namespace StellaOps.Platform.WebService.Tests;
+
+public sealed class CompatibilityEndpointsTests : IClassFixture
+{
+ private readonly PlatformWebApplicationFactory _factory;
+
+ public CompatibilityEndpointsTests(PlatformWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task ConsoleStatus_ReturnsDeterministicPayload()
+ {
+ using var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
+
+ var response = await client.GetAsync("/api/console/status", TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.True(payload.TryGetProperty("healthy", out var healthy));
+ Assert.True(healthy.GetBoolean());
+ Assert.True(payload.GetProperty("backlog").GetInt32() > 0);
+ Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("lastCompletedRunId").GetString()));
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task ConsoleRunFirstSignal_ReturnsCompatibilitySnapshot_AndHonorsConditionalRequests()
+ {
+ using var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
+
+ var response = await client.GetAsync("/api/console/runs/run::demo-prod::20260309/first-signal", TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.Equal("run::demo-prod::20260309", payload.GetProperty("runId").GetString());
+ Assert.Equal("completed", payload.GetProperty("firstSignal").GetProperty("type").GetString());
+ Assert.Equal("snapshot", payload.GetProperty("firstSignal").GetProperty("step").GetString());
+ Assert.Equal("run", payload.GetProperty("firstSignal").GetProperty("artifact").GetProperty("kind").GetString());
+
+ var etag = response.Headers.ETag?.Tag;
+ Assert.False(string.IsNullOrWhiteSpace(etag));
+ Assert.Contains("compatibility", string.Join(", ", response.Headers.GetValues("Cache-Status")));
+
+ using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/api/console/runs/run::demo-prod::20260309/first-signal");
+ conditionalRequest.Headers.Add("X-StellaOps-Tenant", "demo-prod");
+ conditionalRequest.Headers.TryAddWithoutValidation("If-None-Match", etag);
+
+ var notModified = await client.SendAsync(conditionalRequest, TestContext.Current.CancellationToken);
+ Assert.Equal(System.Net.HttpStatusCode.NotModified, notModified.StatusCode);
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task AocMetrics_RespectRequestedWindow()
+ {
+ using var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
+
+ var response = await client.GetAsync("/api/v1/aoc/metrics?windowMinutes=60", TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(12870, payload.GetProperty("totalCount").GetInt32());
+ Assert.Equal(60, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
+ Assert.True(payload.GetProperty("recentViolations").GetArrayLength() > 0);
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task AocMetrics_DefaultWindowWhenCallerOmitsWindowMinutes()
+ {
+ using var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
+
+ var response = await client.GetAsync("/api/v1/aoc/metrics", TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(1440, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task AocComplianceEndpoints_ReturnDashboardAndRetryableViolations()
+ {
+ using var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
+
+ var dashboardResponse = await client.GetAsync("/api/v1/aoc/compliance/dashboard", TestContext.Current.CancellationToken);
+ dashboardResponse.EnsureSuccessStatusCode();
+ var dashboard = await dashboardResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.True(dashboard.TryGetProperty("metrics", out _));
+ Assert.True(dashboard.GetProperty("recentViolations").GetArrayLength() > 0);
+
+ var retryResponse = await client.PostAsJsonAsync(
+ "/api/v1/aoc/compliance/violations/viol-001/retry",
+ new { },
+ TestContext.Current.CancellationToken);
+ retryResponse.EnsureSuccessStatusCode();
+ var retryPayload = await retryResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.True(retryPayload.GetProperty("success").GetBoolean());
+ }
+}
diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs
index a3282aeb4..3368b673b 100644
--- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs
+++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs
@@ -2,6 +2,10 @@ using System;
using System.Linq;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using StellaOps.Auth.Abstractions;
+using StellaOps.Auth.ServerIntegration;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
@@ -124,4 +128,29 @@ public sealed class QuotaEndpointsTests : IClassFixture());
}
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public async Task QuotaPolicies_AcceptLegacyOrchQuotaScope()
+ {
+ var policyProvider = factory.Services.GetRequiredService();
+
+ var readPolicy = await policyProvider.GetPolicyAsync("platform.quota.read");
+ var adminPolicy = await policyProvider.GetPolicyAsync("platform.quota.admin");
+
+ Assert.NotNull(readPolicy);
+ Assert.NotNull(adminPolicy);
+
+ var readScopes = Assert.Single(readPolicy!.Requirements.OfType()).RequiredScopes;
+ var readRequirement = Assert.Single(readPolicy!.Requirements.OfType());
+ var adminRequirement = Assert.Single(adminPolicy!.Requirements.OfType());
+ var adminScopes = adminRequirement.RequiredScopes;
+
+ Assert.Contains(StellaOpsScopes.OrchQuota, readScopes);
+ Assert.Contains("quota.read", readScopes);
+ Assert.Contains(StellaOpsScopes.OrchQuota, adminScopes);
+ Assert.Contains("quota.admin", adminScopes);
+ Assert.False(readRequirement.RequireAllScopes);
+ Assert.False(adminRequirement.RequireAllScopes);
+ }
}
diff --git a/src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs b/src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs
new file mode 100644
index 000000000..9a01a91ca
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs
@@ -0,0 +1,388 @@
+using System.Collections.Concurrent;
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.Abstractions;
+using StellaOps.Auth.ServerIntegration;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+
+namespace StellaOps.Policy.Gateway.Endpoints;
+
+public static class GovernanceCompatibilityEndpoints
+{
+ private static readonly ConcurrentDictionary TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
+ private static readonly ConcurrentDictionary StalenessStates = new(StringComparer.OrdinalIgnoreCase);
+
+ public static void MapGovernanceCompatibilityEndpoints(this WebApplication app)
+ {
+ var governance = app.MapGroup("/api/v1/governance")
+ .WithTags("Governance Compatibility")
+ .RequireTenant();
+
+ governance.MapGet("/trust-weights", (
+ HttpContext context,
+ [FromQuery] string? projectId,
+ TimeProvider timeProvider) =>
+ {
+ var scope = ResolveScope(context, projectId);
+ var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
+ return Results.Ok(state.ToResponse());
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
+
+ governance.MapPut("/trust-weights/{weightId}", (
+ HttpContext context,
+ string weightId,
+ [FromBody] TrustWeightWriteModel request,
+ [FromQuery] string? projectId,
+ TimeProvider timeProvider) =>
+ {
+ var scope = ResolveScope(context, projectId);
+ var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
+ var updated = state.Upsert(weightId, request, timeProvider, StellaOpsTenantResolver.ResolveActor(context));
+ return Results.Ok(updated);
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
+
+ governance.MapDelete("/trust-weights/{weightId}", (
+ HttpContext context,
+ string weightId,
+ [FromQuery] string? projectId,
+ TimeProvider timeProvider) =>
+ {
+ var scope = ResolveScope(context, projectId);
+ var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
+ state.Delete(weightId, timeProvider);
+ return Results.NoContent();
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
+
+ governance.MapPost("/trust-weights/preview-impact", (
+ [FromBody] TrustWeightPreviewRequest request) =>
+ {
+ var weights = request.Weights ?? [];
+ var severityChanges = weights.Count(static weight => weight.Weight >= 1.2m);
+ var decisionChanges = weights.Count(static weight => weight.Active != false);
+
+ var payload = new
+ {
+ affectedVulnerabilities = Math.Max(3, weights.Count * 9),
+ severityChanges,
+ decisionChanges,
+ sampleAffected = weights.Take(3).Select((weight, index) => new
+ {
+ findingId = $"finding-{index + 1:000}",
+ componentPurl = $"pkg:oci/{weight.IssuerId ?? "stellaops"}/runtime-{index + 1}@sha256:{(index + 1).ToString("D4")}",
+ advisoryId = $"CVE-2026-{1400 + index}",
+ currentSeverity = index == 0 ? "high" : "medium",
+ projectedSeverity = weight.Weight >= 1.2m ? "critical" : "high",
+ currentDecision = "warn",
+ projectedDecision = weight.Active != false ? "deny" : "warn"
+ }).ToArray(),
+ severityTransitions = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["medium->high"] = Math.Max(1, severityChanges),
+ ["high->critical"] = Math.Max(1, severityChanges / 2)
+ }
+ };
+
+ return Results.Ok(payload);
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
+
+ governance.MapGet("/staleness/config", (
+ HttpContext context,
+ [FromQuery] string? projectId,
+ TimeProvider timeProvider) =>
+ {
+ var scope = ResolveScope(context, projectId);
+ var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
+ return Results.Ok(state.ToResponse());
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
+
+ governance.MapPut("/staleness/config/{dataType}", (
+ HttpContext context,
+ string dataType,
+ [FromBody] StalenessConfigWriteModel request,
+ [FromQuery] string? projectId,
+ TimeProvider timeProvider) =>
+ {
+ var scope = ResolveScope(context, projectId);
+ var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
+ var updated = state.Upsert(dataType, request, timeProvider);
+ return Results.Ok(updated);
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
+
+ governance.MapGet("/staleness/status", (
+ HttpContext context,
+ [FromQuery] string? projectId,
+ TimeProvider timeProvider) =>
+ {
+ var scope = ResolveScope(context, projectId);
+ var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
+ return Results.Ok(state.BuildStatus());
+ }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
+ }
+
+ private static GovernanceScope ResolveScope(HttpContext context, string? projectId)
+ {
+ if (!StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error))
+ {
+ throw new InvalidOperationException($"Tenant resolution failed: {error ?? "tenant_missing"}");
+ }
+
+ var scopedProject = string.IsNullOrWhiteSpace(projectId)
+ ? StellaOpsTenantResolver.ResolveProject(context)
+ : projectId.Trim();
+
+ return new GovernanceScope(
+ tenantId,
+ string.IsNullOrWhiteSpace(scopedProject) ? null : scopedProject,
+ string.IsNullOrWhiteSpace(scopedProject) ? tenantId : $"{tenantId}:{scopedProject}");
+ }
+
+ private static TrustWeightConfigState CreateDefaultTrustWeightState(string tenantId, string? projectId, TimeProvider timeProvider)
+ {
+ var now = timeProvider.GetUtcNow().ToString("O");
+ return new TrustWeightConfigState(
+ tenantId,
+ projectId,
+ now,
+ "\"trust-weights-v1\"",
+ [
+ new TrustWeightRecord("tw-001", "cisa", "CISA", "cisa", 1.50m, 1, true, "Government authoritative source", now, "system"),
+ new TrustWeightRecord("tw-002", "nist", "NIST NVD", "nist", 1.30m, 2, true, "Primary CVE source", now, "system"),
+ new TrustWeightRecord("tw-003", "vendor-redhat", "Red Hat", "vendor", 1.20m, 3, true, "Trusted vendor feed", now, "system")
+ ]);
+ }
+
+ private static StalenessConfigState CreateDefaultStalenessState(string tenantId, string? projectId, TimeProvider timeProvider)
+ {
+ var now = timeProvider.GetUtcNow().ToString("O");
+ return new StalenessConfigState(
+ tenantId,
+ projectId,
+ now,
+ "\"staleness-v1\"",
+ [
+ new StalenessConfigRecord("sbom", BuildThresholds(7, 14, 30, 45), true, 12),
+ new StalenessConfigRecord("vulnerability_data", BuildThresholds(1, 3, 7, 14), true, 6),
+ new StalenessConfigRecord("vex_statements", BuildThresholds(3, 7, 14, 21), true, 12),
+ new StalenessConfigRecord("policy", BuildThresholds(14, 30, 45, 60), false, 24),
+ new StalenessConfigRecord("attestation", BuildThresholds(7, 14, 21, 30), true, 8),
+ new StalenessConfigRecord("scan_result", BuildThresholds(1, 2, 5, 10), true, 4)
+ ]);
+ }
+
+ private static List BuildThresholds(int fresh, int aging, int stale, int expired) =>
+ [
+ new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
+ new("aging", aging, "medium", [new StalenessActionRecord("notify", "Approaching review window.")]),
+ new("stale", stale, "high", [new StalenessActionRecord("flag_review", "Operator review required.")]),
+ new("expired", expired, "critical", [new StalenessActionRecord("block", "Fresh data required before continue.")])
+ ];
+
+ private sealed record GovernanceScope(string TenantId, string? ProjectId, string Key);
+
+ private sealed class TrustWeightConfigState(
+ string tenantId,
+ string? projectId,
+ string modifiedAt,
+ string etag,
+ List weights)
+ {
+ public string TenantId { get; private set; } = tenantId;
+ public string? ProjectId { get; private set; } = projectId;
+ public string ModifiedAt { get; private set; } = modifiedAt;
+ public string Etag { get; private set; } = etag;
+ public List Weights { get; } = weights;
+
+ public object ToResponse() => new
+ {
+ tenantId = TenantId,
+ projectId = ProjectId,
+ weights = Weights.OrderBy(static weight => weight.Priority).ThenBy(static weight => weight.IssuerName, StringComparer.OrdinalIgnoreCase),
+ defaultWeight = 1.0m,
+ modifiedAt = ModifiedAt,
+ etag = Etag
+ };
+
+ public object Upsert(string routeWeightId, TrustWeightWriteModel request, TimeProvider timeProvider, string actor)
+ {
+ var effectiveId = string.IsNullOrWhiteSpace(request.Id) ? routeWeightId : request.Id.Trim();
+ var index = Weights.FindIndex(weight => string.Equals(weight.Id, effectiveId, StringComparison.OrdinalIgnoreCase));
+ var existing = index >= 0 ? Weights[index] : null;
+ var now = timeProvider.GetUtcNow().ToString("O");
+ var updated = new TrustWeightRecord(
+ effectiveId,
+ request.IssuerId?.Trim() ?? existing?.IssuerId ?? effectiveId,
+ request.IssuerName?.Trim() ?? existing?.IssuerName ?? effectiveId,
+ NormalizeSource(request.Source ?? existing?.Source),
+ request.Weight ?? existing?.Weight ?? 1.0m,
+ request.Priority ?? existing?.Priority ?? Weights.Count + 1,
+ request.Active ?? existing?.Active ?? true,
+ request.Reason?.Trim() ?? existing?.Reason,
+ now,
+ actor);
+
+ if (index >= 0)
+ {
+ Weights[index] = updated;
+ }
+ else
+ {
+ Weights.Add(updated);
+ }
+
+ ModifiedAt = now;
+ Etag = $"\"trust-weights-{Weights.Count}-{now}\"";
+ return updated;
+ }
+
+ public void Delete(string weightId, TimeProvider timeProvider)
+ {
+ Weights.RemoveAll(weight => string.Equals(weight.Id, weightId, StringComparison.OrdinalIgnoreCase));
+ ModifiedAt = timeProvider.GetUtcNow().ToString("O");
+ Etag = $"\"trust-weights-{Weights.Count}-{ModifiedAt}\"";
+ }
+ }
+
+ private sealed class StalenessConfigState(
+ string tenantId,
+ string? projectId,
+ string modifiedAt,
+ string etag,
+ List configs)
+ {
+ public string TenantId { get; private set; } = tenantId;
+ public string? ProjectId { get; private set; } = projectId;
+ public string ModifiedAt { get; private set; } = modifiedAt;
+ public string Etag { get; private set; } = etag;
+ public List Configs { get; } = configs;
+
+ public object ToResponse() => new
+ {
+ tenantId = TenantId,
+ projectId = ProjectId,
+ configs = Configs.OrderBy(static config => config.DataType, StringComparer.OrdinalIgnoreCase),
+ modifiedAt = ModifiedAt,
+ etag = Etag
+ };
+
+ public object Upsert(string dataType, StalenessConfigWriteModel request, TimeProvider timeProvider)
+ {
+ var effectiveType = string.IsNullOrWhiteSpace(dataType) ? request.DataType?.Trim() ?? "sbom" : dataType.Trim();
+ var thresholds = request.Thresholds?.Count > 0
+ ? request.Thresholds.Select(threshold => new StalenessThresholdRecord(
+ NormalizeLevel(threshold.Level),
+ threshold.AgeDays,
+ NormalizeSeverity(threshold.Severity),
+ threshold.Actions?.Select(action => new StalenessActionRecord(NormalizeActionType(action.Type), action.Message, action.Channels)).ToList() ?? [])).ToList()
+ : BuildThresholds(7, 14, 30, 45);
+
+ var updated = new StalenessConfigRecord(
+ effectiveType,
+ thresholds,
+ request.Enabled ?? true,
+ request.GracePeriodHours ?? 12);
+
+ var index = Configs.FindIndex(config => string.Equals(config.DataType, effectiveType, StringComparison.OrdinalIgnoreCase));
+ if (index >= 0)
+ {
+ Configs[index] = updated;
+ }
+ else
+ {
+ Configs.Add(updated);
+ }
+
+ ModifiedAt = timeProvider.GetUtcNow().ToString("O");
+ Etag = $"\"staleness-{Configs.Count}-{ModifiedAt}\"";
+ return updated;
+ }
+
+ public object[] BuildStatus() =>
+ Configs.Select((config, index) => new
+ {
+ dataType = config.DataType,
+ itemId = $"{config.DataType}-asset-{index + 1}",
+ itemName = $"{config.DataType.Replace('_', ' ')} snapshot {index + 1}",
+ lastUpdatedAt = DateTimeOffset.Parse(ModifiedAt).AddDays(-(index + 1) * 3).ToString("O"),
+ ageDays = (index + 1) * 3,
+ level = index == 0 ? "fresh" : index == 1 ? "aging" : index == 2 ? "stale" : "expired",
+ blocked = index >= 2 && config.Enabled
+ }).ToArray();
+ }
+
+ private static string NormalizeSource(string? source) =>
+ string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
+
+ private static string NormalizeSeverity(string? severity) =>
+ string.IsNullOrWhiteSpace(severity) ? "medium" : severity.Trim().ToLowerInvariant();
+
+ private static string NormalizeLevel(string? level) =>
+ string.IsNullOrWhiteSpace(level) ? "fresh" : level.Trim().ToLowerInvariant();
+
+ private static string NormalizeActionType(string? actionType) =>
+ string.IsNullOrWhiteSpace(actionType) ? "warn" : actionType.Trim().ToLowerInvariant();
+
+ private sealed record TrustWeightRecord(
+ string Id,
+ string IssuerId,
+ string IssuerName,
+ string Source,
+ decimal Weight,
+ int Priority,
+ bool Active,
+ string? Reason,
+ string ModifiedAt,
+ string ModifiedBy);
+
+ private sealed record StalenessConfigRecord(
+ string DataType,
+ List Thresholds,
+ bool Enabled,
+ int GracePeriodHours);
+
+ private sealed record StalenessThresholdRecord(
+ string Level,
+ int AgeDays,
+ string Severity,
+ List Actions);
+
+ private sealed record StalenessActionRecord(
+ string Type,
+ string? Message = null,
+ string[]? Channels = null);
+}
+
+public sealed record TrustWeightWriteModel
+{
+ public string? Id { get; init; }
+ public string? IssuerId { get; init; }
+ public string? IssuerName { get; init; }
+ public string? Source { get; init; }
+ public decimal? Weight { get; init; }
+ public int? Priority { get; init; }
+ public bool? Active { get; init; }
+ public string? Reason { get; init; }
+}
+
+public sealed record TrustWeightPreviewRequest(IReadOnlyList? Weights);
+
+public sealed record StalenessConfigWriteModel
+{
+ public string? DataType { get; init; }
+ public IReadOnlyList? Thresholds { get; init; }
+ public bool? Enabled { get; init; }
+ public int? GracePeriodHours { get; init; }
+}
+
+public sealed record StalenessThresholdWriteModel
+{
+ public string? Level { get; init; }
+ public int AgeDays { get; init; }
+ public string? Severity { get; init; }
+ public IReadOnlyList? Actions { get; init; }
+}
+
+public sealed record StalenessActionWriteModel
+{
+ public string? Type { get; init; }
+ public string? Message { get; init; }
+ public string[]? Channels { get; init; }
+}
diff --git a/src/Policy/StellaOps.Policy.Gateway/Program.cs b/src/Policy/StellaOps.Policy.Gateway/Program.cs
index db98ece35..93dd5ad1a 100644
--- a/src/Policy/StellaOps.Policy.Gateway/Program.cs
+++ b/src/Policy/StellaOps.Policy.Gateway/Program.cs
@@ -676,6 +676,9 @@ app.MapExceptionEndpoints();
// Delta management endpoints
app.MapDeltasEndpoints();
+// Policy simulation compatibility endpoints for live console routes.
+app.MapPolicySimulationEndpoints();
+
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
app.MapGateEndpoints();
@@ -693,6 +696,7 @@ app.MapExceptionApprovalEndpoints();
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
app.MapGovernanceEndpoints();
+app.MapGovernanceCompatibilityEndpoints();
// Advisory source impact/conflict endpoints (Sprint: SPRINT_20260219_008, Task: BE8-05)
app.MapAdvisorySourcePolicyEndpoints();
diff --git a/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs
new file mode 100644
index 000000000..e15132c23
--- /dev/null
+++ b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs
@@ -0,0 +1,112 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using StellaOps.TestKit;
+using Xunit;
+
+namespace StellaOps.Policy.Gateway.Tests;
+
+public sealed class GovernanceCompatibilityEndpointsTests : IClassFixture
+{
+ private readonly HttpClient _client;
+
+ public GovernanceCompatibilityEndpointsTests(TestPolicyGatewayFactory factory)
+ {
+ _client = factory.CreateClient();
+ _client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task GetTrustWeights_ReturnsCompatibilityShape()
+ {
+ var response = await _client.GetAsync("/api/v1/governance/trust-weights", TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.Equal("test-tenant", payload.GetProperty("tenantId").GetString());
+ Assert.True(payload.GetProperty("weights").GetArrayLength() >= 3);
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task PutTrustWeight_PersistsUpdatedPriorityAndWeight()
+ {
+ var updateResponse = await _client.PutAsJsonAsync(
+ "/api/v1/governance/trust-weights/tw-002",
+ new
+ {
+ id = "tw-002",
+ issuerId = "nist",
+ issuerName = "NIST NVD",
+ source = "nist",
+ weight = 1.75m,
+ priority = 1,
+ active = true,
+ reason = "Escalated for live route verification"
+ },
+ TestContext.Current.CancellationToken);
+ updateResponse.EnsureSuccessStatusCode();
+
+ var listResponse = await _client.GetAsync("/api/v1/governance/trust-weights", TestContext.Current.CancellationToken);
+ listResponse.EnsureSuccessStatusCode();
+ var payload = await listResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ var updated = payload.GetProperty("weights").EnumerateArray().Single(item => item.GetProperty("id").GetString() == "tw-002");
+
+ Assert.Equal(1.75m, updated.GetProperty("weight").GetDecimal());
+ Assert.Equal(1, updated.GetProperty("priority").GetInt32());
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task PreviewImpact_ReturnsAffectedFindingSamples()
+ {
+ var response = await _client.PostAsJsonAsync(
+ "/api/v1/governance/trust-weights/preview-impact",
+ new
+ {
+ weights = new[]
+ {
+ new { issuerId = "cisa", issuerName = "CISA", source = "cisa", weight = 1.6m, active = true }
+ }
+ },
+ TestContext.Current.CancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.True(payload.GetProperty("affectedVulnerabilities").GetInt32() > 0);
+ Assert.True(payload.GetProperty("sampleAffected").GetArrayLength() > 0);
+ }
+
+ [Trait("Category", TestCategories.Integration)]
+ [Fact]
+ public async Task StalenessEndpoints_ReturnConfigAndStatusUpdates()
+ {
+ var updateResponse = await _client.PutAsJsonAsync(
+ "/api/v1/governance/staleness/config/sbom",
+ new
+ {
+ dataType = "sbom",
+ enabled = false,
+ gracePeriodHours = 48,
+ thresholds = new[]
+ {
+ new { level = "fresh", ageDays = 14, severity = "low", actions = new[] { new { type = "warn", message = "Fresh enough" } } }
+ }
+ },
+ TestContext.Current.CancellationToken);
+ updateResponse.EnsureSuccessStatusCode();
+
+ var configResponse = await _client.GetAsync("/api/v1/governance/staleness/config", TestContext.Current.CancellationToken);
+ configResponse.EnsureSuccessStatusCode();
+ var config = await configResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ var sbomConfig = config.GetProperty("configs").EnumerateArray().Single(item => item.GetProperty("dataType").GetString() == "sbom");
+ Assert.False(sbomConfig.GetProperty("enabled").GetBoolean());
+ Assert.Equal(48, sbomConfig.GetProperty("gracePeriodHours").GetInt32());
+
+ var statusResponse = await _client.GetAsync("/api/v1/governance/staleness/status", TestContext.Current.CancellationToken);
+ statusResponse.EnsureSuccessStatusCode();
+ var status = await statusResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.True(status.ValueKind == JsonValueKind.Array);
+ Assert.True(status.GetArrayLength() > 0);
+ }
+}
diff --git a/src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs b/src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs
new file mode 100644
index 000000000..a9f2ae526
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs
@@ -0,0 +1,215 @@
+using StellaOps.Auth.Abstractions;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Signals.Hosting;
+using StellaOps.Signals.Options;
+using StellaOps.Signals.Routing;
+
+namespace StellaOps.Signals;
+
+internal static class CompatibilityApiV1Endpoints
+{
+ private static readonly CompatibilitySignalRecord[] Signals =
+ [
+ new(
+ "sig-001",
+ "ci_build",
+ "gitea",
+ "completed",
+ new Dictionary
+ {
+ ["host"] = "build-agent-01",
+ ["runtime"] = "ebpf",
+ ["probeStatus"] = "healthy",
+ ["latencyMs"] = 41
+ },
+ "corr-001",
+ "sha256:001",
+ new[] { "update-runtime-health" },
+ "2026-03-09T08:10:00Z",
+ "2026-03-09T08:10:02Z",
+ null),
+ new(
+ "sig-002",
+ "ci_deploy",
+ "internal",
+ "processing",
+ new Dictionary
+ {
+ ["host"] = "deploy-stage-02",
+ ["runtime"] = "etw",
+ ["probeStatus"] = "degraded",
+ ["latencyMs"] = 84
+ },
+ "corr-002",
+ "sha256:002",
+ new[] { "refresh-rollout-state" },
+ "2026-03-09T08:12:00Z",
+ null,
+ null),
+ new(
+ "sig-003",
+ "registry_push",
+ "harbor",
+ "failed",
+ new Dictionary
+ {
+ ["host"] = "registry-sync-01",
+ ["runtime"] = "dyld",
+ ["probeStatus"] = "failed",
+ ["latencyMs"] = 132
+ },
+ "corr-003",
+ "sha256:003",
+ new[] { "retry-mirror" },
+ "2026-03-09T08:13:00Z",
+ "2026-03-09T08:13:05Z",
+ "Registry callback timed out."),
+ new(
+ "sig-004",
+ "scan_complete",
+ "internal",
+ "completed",
+ new Dictionary
+ {
+ ["host"] = "scanner-03",
+ ["runtime"] = "ebpf",
+ ["probeStatus"] = "healthy",
+ ["latencyMs"] = 58
+ },
+ "corr-004",
+ "sha256:004",
+ new[] { "refresh-risk-snapshot" },
+ "2026-03-09T08:16:00Z",
+ "2026-03-09T08:16:01Z",
+ null),
+ new(
+ "sig-005",
+ "policy_eval",
+ "internal",
+ "received",
+ new Dictionary
+ {
+ ["host"] = "policy-runner-01",
+ ["runtime"] = "unknown",
+ ["probeStatus"] = "degraded",
+ ["latencyMs"] = 73
+ },
+ "corr-005",
+ "sha256:005",
+ new[] { "await-policy-evaluation" },
+ "2026-03-09T08:18:00Z",
+ null,
+ null)
+ ];
+
+ public static IEndpointRouteBuilder MapSignalsCompatibilityEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v1/signals").RequireTenant();
+
+ group.MapGet("", (
+ HttpContext context,
+ SignalsOptions options,
+ SignalsSealedModeMonitor sealedModeMonitor,
+ string? type,
+ string? status,
+ string? provider,
+ int? limit,
+ string? cursor) =>
+ {
+ if (!Program.TryAuthorizeAny(context, [SignalsPolicies.Read, StellaOpsScopes.OrchRead], options.Authority.AllowAnonymousFallback, out var authFailure))
+ {
+ return authFailure ?? Results.Unauthorized();
+ }
+
+ if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
+ {
+ return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ var filtered = ApplyFilters(type, status, provider);
+ var offset = ParseCursor(cursor);
+ var pageSize = Math.Clamp(limit ?? 50, 1, 200);
+ var items = filtered.Skip(offset).Take(pageSize).ToArray();
+ var nextCursor = offset + pageSize < filtered.Length ? (offset + pageSize).ToString() : null;
+
+ return Results.Ok(new
+ {
+ items,
+ total = filtered.Length,
+ cursor = nextCursor
+ });
+ });
+
+ group.MapGet("/stats", (
+ HttpContext context,
+ SignalsOptions options,
+ SignalsSealedModeMonitor sealedModeMonitor) =>
+ {
+ if (!Program.TryAuthorizeAny(context, [SignalsPolicies.Read, StellaOpsScopes.OrchRead], options.Authority.AllowAnonymousFallback, out var authFailure))
+ {
+ return authFailure ?? Results.Unauthorized();
+ }
+
+ if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
+ {
+ return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ return Results.Ok(BuildStats(Signals));
+ });
+
+ return app;
+ }
+
+ private static CompatibilitySignalRecord[] ApplyFilters(string? type, string? status, string? provider) =>
+ Signals
+ .Where(signal => string.IsNullOrWhiteSpace(type) || string.Equals(signal.Type, type, StringComparison.OrdinalIgnoreCase))
+ .Where(signal => string.IsNullOrWhiteSpace(status) || string.Equals(signal.Status, status, StringComparison.OrdinalIgnoreCase))
+ .Where(signal => string.IsNullOrWhiteSpace(provider) || string.Equals(signal.Provider, provider, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ private static int ParseCursor(string? cursor) =>
+ int.TryParse(cursor, out var offset) && offset >= 0 ? offset : 0;
+
+ private static object BuildStats(IReadOnlyCollection signals)
+ {
+ var byType = signals
+ .GroupBy(signal => signal.Type, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
+ var byStatus = signals
+ .GroupBy(signal => signal.Status, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
+ var byProvider = signals
+ .GroupBy(signal => signal.Provider, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
+ var successful = signals.Count(signal => string.Equals(signal.Status, "completed", StringComparison.OrdinalIgnoreCase));
+ var latencySamples = signals
+ .Select(signal => signal.Payload.TryGetValue("latencyMs", out var value) ? value : null)
+ .OfType()
+ .ToArray();
+
+ return new
+ {
+ total = signals.Count,
+ byType,
+ byStatus,
+ byProvider,
+ lastHourCount = signals.Count,
+ successRate = signals.Count == 0 ? 100 : Math.Round((successful / (double)signals.Count) * 100, 2),
+ avgProcessingMs = latencySamples.Length == 0 ? 0 : Math.Round(latencySamples.Average(), 2)
+ };
+ }
+
+ internal sealed record CompatibilitySignalRecord(
+ string Id,
+ string Type,
+ string Provider,
+ string Status,
+ IReadOnlyDictionary Payload,
+ string? CorrelationId,
+ string? ArtifactRef,
+ IReadOnlyCollection TriggeredActions,
+ string ReceivedAt,
+ string? ProcessedAt,
+ string? Error);
+}
diff --git a/src/Signals/StellaOps.Signals/Program.cs b/src/Signals/StellaOps.Signals/Program.cs
index 8713d7af7..7a944bc54 100644
--- a/src/Signals/StellaOps.Signals/Program.cs
+++ b/src/Signals/StellaOps.Signals/Program.cs
@@ -1024,6 +1024,8 @@ signalsGroup.MapPost("/reachability/recompute", async Task (
}
}).WithName("SignalsReachabilityRecompute");
+StellaOps.Signals.CompatibilityApiV1Endpoints.MapSignalsCompatibilityEndpoints(app);
+
await app.LoadTranslationsAsync();
@@ -1072,6 +1074,59 @@ internal partial class Program
return false;
}
+ internal static bool TryAuthorizeAny(HttpContext httpContext, IReadOnlyCollection requiredScopes, bool fallbackAllowed, out IResult? failure)
+ {
+ ArgumentNullException.ThrowIfNull(httpContext);
+ ArgumentNullException.ThrowIfNull(requiredScopes);
+
+ var scopes = requiredScopes
+ .Where(static scope => !string.IsNullOrWhiteSpace(scope))
+ .Select(static scope => scope.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (scopes.Length == 0)
+ {
+ failure = Results.StatusCode(StatusCodes.Status403Forbidden);
+ return false;
+ }
+
+ if (httpContext.User?.Identity?.IsAuthenticated == true)
+ {
+ if (scopes.Any(scope => TokenScopeAuthorizer.HasScope(httpContext.User, scope)))
+ {
+ failure = null;
+ return true;
+ }
+
+ failure = Results.StatusCode(StatusCodes.Status403Forbidden);
+ return false;
+ }
+
+ if (!fallbackAllowed)
+ {
+ failure = Results.Unauthorized();
+ return false;
+ }
+
+ if (!httpContext.Request.Headers.TryGetValue("X-Scopes", out var scopesHeader) ||
+ string.IsNullOrWhiteSpace(scopesHeader.ToString()))
+ {
+ failure = Results.Unauthorized();
+ return false;
+ }
+
+ var principal = HeaderScopeAuthorizer.CreatePrincipal(scopesHeader.ToString());
+ if (scopes.Any(scope => HeaderScopeAuthorizer.HasScope(principal, scope)))
+ {
+ failure = null;
+ return true;
+ }
+
+ failure = Results.StatusCode(StatusCodes.Status403Forbidden);
+ return false;
+ }
+
internal static bool TryEnsureSealedMode(SignalsSealedModeMonitor monitor, out IResult? failure)
{
if (!monitor.EnforcementEnabled)
diff --git a/src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs b/src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs
new file mode 100644
index 000000000..5c3fd048a
--- /dev/null
+++ b/src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs
@@ -0,0 +1,73 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using StellaOps.Auth.Abstractions;
+using StellaOps.Signals.Routing;
+using Xunit;
+
+namespace StellaOps.Signals.Tests;
+
+public sealed class ProgramCompatibilityTests
+{
+ [Fact]
+ public void TryAuthorizeAny_AllowsLegacyOrchReadTokenScope()
+ {
+ var context = new DefaultHttpContext
+ {
+ User = CreatePrincipal(StellaOpsScopes.OrchRead)
+ };
+
+ var authorized = Program.TryAuthorizeAny(
+ context,
+ [SignalsPolicies.Read, StellaOpsScopes.OrchRead],
+ fallbackAllowed: false,
+ out var failure);
+
+ Assert.True(authorized);
+ Assert.Null(failure);
+ }
+
+ [Fact]
+ public void TryAuthorizeAny_AllowsHeaderFallbackForLegacyScope()
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-Scopes"] = StellaOpsScopes.OrchRead;
+
+ var authorized = Program.TryAuthorizeAny(
+ context,
+ [SignalsPolicies.Read, StellaOpsScopes.OrchRead],
+ fallbackAllowed: true,
+ out var failure);
+
+ Assert.True(authorized);
+ Assert.Null(failure);
+ }
+
+ [Fact]
+ public void TryAuthorizeAny_RejectsWhenNoCompatibleScopeExists()
+ {
+ var context = new DefaultHttpContext
+ {
+ User = CreatePrincipal(StellaOpsScopes.PolicyRead)
+ };
+
+ var authorized = Program.TryAuthorizeAny(
+ context,
+ [SignalsPolicies.Read, StellaOpsScopes.OrchRead],
+ fallbackAllowed: false,
+ out var failure);
+
+ Assert.False(authorized);
+ Assert.NotNull(failure);
+ }
+
+ private static ClaimsPrincipal CreatePrincipal(string scope)
+ {
+ var identity = new ClaimsIdentity(
+ [
+ new Claim(StellaOpsClaimTypes.Scope, scope),
+ new Claim(StellaOpsClaimTypes.ScopeItem, scope)
+ ], "Bearer");
+
+ return new ClaimsPrincipal(identity);
+ }
+}