diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs b/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs
index 3c6f00795..4512ec41b 100644
--- a/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs
+++ b/src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs
@@ -36,6 +36,7 @@ using StellaOps.ExportCenter.Infrastructure.Db;
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
using StellaOps.Integrations.Persistence;
using StellaOps.Replay.WebService;
+using StellaOps.ReleaseOrchestrator.Persistence.Postgres;
using StellaOps.RiskEngine.Infrastructure.Stores;
namespace StellaOps.Platform.Database;
@@ -363,6 +364,14 @@ public sealed class RiskEngineMigrationModulePlugin : IMigrationModulePlugin
resourcePrefix: "StellaOps.RiskEngine.Infrastructure.Migrations");
}
+public sealed class ReleaseOrchestratorMigrationModulePlugin : IMigrationModulePlugin
+{
+ public MigrationModuleInfo Module { get; } = new(
+ name: "ReleaseOrchestrator",
+ schemaName: "release_orchestrator",
+ migrationsAssembly: typeof(ReleaseOrchestratorDataSource).Assembly);
+}
+
public sealed class FindingsLedgerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj b/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj
index aacc9db90..31d627680 100644
--- a/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj
+++ b/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj
@@ -51,6 +51,7 @@
+
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs
index 10d057699..d975643c3 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Contracts/AuditLedgerContracts.cs
@@ -1,5 +1,5 @@
-using StellaOps.JobEngine.Core.Domain;
-using StellaOps.JobEngine.Infrastructure.Repositories;
+using StellaOps.ReleaseOrchestrator.Persistence.Domain;
+using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
namespace StellaOps.ReleaseOrchestrator.WebApi.Contracts;
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs
index faf7cc826..7f208c42c 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/AuditEndpoints.cs
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
-using StellaOps.JobEngine.Core.Domain;
-using StellaOps.JobEngine.Infrastructure.Repositories;
+using StellaOps.ReleaseOrchestrator.Persistence.Domain;
+using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
using StellaOps.ReleaseOrchestrator.WebApi.Contracts;
using StellaOps.ReleaseOrchestrator.WebApi.Services;
using static StellaOps.Localization.T;
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs
index 1cb2dc433..6f0410c19 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/FirstSignalEndpoints.cs
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
-using StellaOps.JobEngine.Core.Services;
+using StellaOps.ReleaseOrchestrator.Persistence.Services;
using StellaOps.ReleaseOrchestrator.WebApi.Contracts;
using StellaOps.ReleaseOrchestrator.WebApi.Services;
using static StellaOps.Localization.T;
@@ -26,7 +26,7 @@ public static class FirstSignalEndpoints
return group;
}
- private static async Task GetFirstSignal(
+ internal static async Task GetFirstSignal(
HttpContext context,
[FromRoute] Guid runId,
[FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs
index babf6466d..9a73eae43 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/JobEngineLegacyEndpoints.cs
@@ -22,6 +22,7 @@ public static class JobEngineLegacyEndpoints
public static IEndpointRouteBuilder MapJobEngineLegacyEndpoints(this IEndpointRouteBuilder app)
{
MapLegacyAuditEndpoints(app);
+ MapLegacyRunsEndpoints(app);
MapLegacyStubEndpoints(app);
return app;
@@ -40,6 +41,33 @@ public static class JobEngineLegacyEndpoints
group.MapGet("events", AuditEndpoints.ListAuditEntriesHandler);
}
+ // -----------------------------------------------------------------------
+ // Runs -- first-signal has a real implementation, delegate to handler
+ // -----------------------------------------------------------------------
+ private static void MapLegacyRunsEndpoints(IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v1/jobengine/runs")
+ .WithTags("JobEngine Legacy - Runs")
+ .RequireAuthorization(ReleaseOrchestratorPolicies.Read)
+ .RequireTenant();
+
+ group.MapGet("{runId:guid}/first-signal", FirstSignalEndpoints.GetFirstSignal);
+
+ // Catch-all for other runs sub-paths not yet migrated
+ group.MapGet("{**rest}", NotImplementedStub)
+ .WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
+ group.MapPost("{**rest}", NotImplementedStub)
+ .WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
+ group.MapPut("{**rest}", NotImplementedStub)
+ .WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
+ group.MapDelete("{**rest}", NotImplementedStub)
+ .WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
+ group.MapGet(string.Empty, NotImplementedStub)
+ .WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
+ group.MapPost(string.Empty, NotImplementedStub)
+ .WithDescription("Legacy jobengine compatibility stub - feature pending migration.");
+ }
+
// -----------------------------------------------------------------------
// Stubs -- features not yet migrated from the monolithic JobEngine
// -----------------------------------------------------------------------
@@ -52,7 +80,6 @@ public static class JobEngineLegacyEndpoints
"/api/v1/jobengine/quotas",
"/api/v1/jobengine/deadletter",
"/api/v1/jobengine/jobs",
- "/api/v1/jobengine/runs",
"/api/v1/jobengine/dag",
"/api/v1/jobengine/pack-runs",
"/api/v1/jobengine/stream",
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs
index d971c32b9..bbd182108 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs
@@ -3,7 +3,7 @@ using StellaOps.Router.AspNet;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Infrastructure.Postgres.Options;
-using StellaOps.JobEngine.Infrastructure;
+using StellaOps.ReleaseOrchestrator.Persistence.Extensions;
using StellaOps.ReleaseOrchestrator.Scripts;
using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
using StellaOps.ReleaseOrchestrator.Scripts.Search;
@@ -26,8 +26,8 @@ builder.Services.AddAuthorization(options =>
options.AddReleaseOrchestratorPolicies();
});
-// JobEngine infrastructure (Postgres repositories: IAuditRepository, IFirstSignalService, etc.)
-builder.Services.AddJobEngineInfrastructure(builder.Configuration);
+// ReleaseOrchestrator persistence (audit + first-signal; own schema, no JobEngine dependency)
+builder.Services.AddReleaseOrchestratorPersistence(builder.Configuration);
// Workflow engine HTTP client (calls workflow service to start workflow instances)
builder.Services.AddHttpClient((sp, client) =>
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs
index 726416bb0..9f2ea00e8 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/EndpointHelpers.cs
@@ -1,4 +1,3 @@
-using StellaOps.JobEngine.Core.Domain;
using System.Text;
namespace StellaOps.ReleaseOrchestrator.WebApi.Services;
diff --git a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj
index 91f6ec30e..83383a8a7 100644
--- a/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj
+++ b/src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/StellaOps.ReleaseOrchestrator.WebApi.csproj
@@ -17,8 +17,7 @@
-
-
+
diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..5a4fe4e85
--- /dev/null
+++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,111 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql;
+using StellaOps.Infrastructure.Postgres.Migrations;
+using StellaOps.Infrastructure.Postgres.Options;
+using StellaOps.ReleaseOrchestrator.Persistence.Hashing;
+using StellaOps.ReleaseOrchestrator.Persistence.Postgres;
+using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
+using StellaOps.ReleaseOrchestrator.Persistence.Services;
+
+namespace StellaOps.ReleaseOrchestrator.Persistence.Extensions;
+
+///
+/// Extension methods for registering ReleaseOrchestrator persistence services.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds ReleaseOrchestrator persistence services (audit + first-signal) to the service collection.
+ /// Replaces the previous AddJobEngineInfrastructure() call with a lean, schema-isolated alternative.
+ ///
+ public static IServiceCollection AddReleaseOrchestratorPersistence(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ // Bind PostgresOptions from config, with connection-string fallback chain
+ services.AddOptions()
+ .Configure(options =>
+ {
+ var section = configuration.GetSection("ReleaseOrchestrator:Postgres");
+ if (section.Exists())
+ {
+ section.Bind(options);
+ }
+
+ // Fallback: reuse the default connection string with release_orchestrator schema
+ if (string.IsNullOrWhiteSpace(options.ConnectionString) || ShouldReplaceConnectionString(options.ConnectionString))
+ {
+ var orchestratorConnection = configuration["Orchestrator:Database:ConnectionString"];
+ if (!string.IsNullOrWhiteSpace(orchestratorConnection))
+ {
+ options.ConnectionString = orchestratorConnection;
+ }
+ else
+ {
+ options.ConnectionString = configuration.GetConnectionString("Default")
+ ?? configuration["ConnectionStrings__Default"]
+ ?? "Host=localhost;Database=stellaops_platform;Username=stellaops;Password=stellaops";
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(options.SchemaName))
+ {
+ options.SchemaName = ReleaseOrchestratorDataSource.DefaultSchemaName;
+ }
+ });
+
+ // Startup migrations (auto-migrate on boot, per repo convention)
+ services.AddStartupMigrations(
+ schemaName: ReleaseOrchestratorDataSource.DefaultSchemaName,
+ moduleName: "ReleaseOrchestrator",
+ migrationsAssembly: typeof(ReleaseOrchestratorDataSource).Assembly);
+
+ // Data source
+ services.AddSingleton();
+
+ // Hashing (for audit hash chain)
+ services.AddSingleton();
+
+ // Repositories
+ services.AddScoped();
+ services.AddScoped();
+
+ // Services
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static bool ShouldReplaceConnectionString(string? configuredConnectionString)
+ {
+ if (string.IsNullOrWhiteSpace(configuredConnectionString))
+ {
+ return true;
+ }
+
+ try
+ {
+ var builder = new NpgsqlConnectionStringBuilder(configuredConnectionString);
+ var host = builder.Host?.Trim();
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ return true;
+ }
+
+ return host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .All(IsLoopbackHost);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsLoopbackHost(string host)
+ {
+ return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
+ || host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase)
+ || host.Equals("::1", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql
new file mode 100644
index 000000000..db1e4746e
--- /dev/null
+++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_initial.sql
@@ -0,0 +1,232 @@
+-- Migration: 001_initial
+-- Creates the release_orchestrator schema with audit + first-signal tables.
+-- Only the 3 tables needed by the release-orchestrator service (vs 30+ in JobEngine).
+
+CREATE SCHEMA IF NOT EXISTS release_orchestrator;
+
+-- ===================================================================
+-- 1. Audit entries (immutable append-only log with hash chain)
+-- ===================================================================
+CREATE TABLE IF NOT EXISTS release_orchestrator.audit_entries (
+ entry_id UUID PRIMARY KEY,
+ tenant_id TEXT NOT NULL,
+ event_type INTEGER NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id UUID NOT NULL,
+ actor_id TEXT NOT NULL,
+ actor_type INTEGER NOT NULL,
+ actor_ip TEXT,
+ user_agent TEXT,
+ http_method TEXT,
+ request_path TEXT,
+ old_state JSONB,
+ new_state JSONB,
+ description TEXT NOT NULL,
+ correlation_id TEXT,
+ previous_entry_hash TEXT,
+ content_hash TEXT NOT NULL,
+ sequence_number BIGINT NOT NULL,
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ metadata JSONB
+);
+
+CREATE INDEX IF NOT EXISTS idx_ro_audit_tenant
+ ON release_orchestrator.audit_entries(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_ro_audit_tenant_time
+ ON release_orchestrator.audit_entries(tenant_id, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS idx_ro_audit_tenant_seq
+ ON release_orchestrator.audit_entries(tenant_id, sequence_number DESC);
+CREATE INDEX IF NOT EXISTS idx_ro_audit_resource
+ ON release_orchestrator.audit_entries(tenant_id, resource_type, resource_id);
+CREATE INDEX IF NOT EXISTS idx_ro_audit_actor
+ ON release_orchestrator.audit_entries(tenant_id, actor_id);
+CREATE INDEX IF NOT EXISTS idx_ro_audit_event_type
+ ON release_orchestrator.audit_entries(tenant_id, event_type);
+CREATE INDEX IF NOT EXISTS idx_ro_audit_correlation
+ ON release_orchestrator.audit_entries(correlation_id)
+ WHERE correlation_id IS NOT NULL;
+
+-- ===================================================================
+-- 2. Audit sequence tracking (per-tenant hash chain head)
+-- ===================================================================
+CREATE TABLE IF NOT EXISTS release_orchestrator.audit_sequences (
+ tenant_id TEXT PRIMARY KEY,
+ last_sequence_number BIGINT NOT NULL DEFAULT 0,
+ last_entry_hash TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- ===================================================================
+-- 3. First signal snapshots (TTFS fast-path cache)
+-- ===================================================================
+CREATE TABLE IF NOT EXISTS release_orchestrator.first_signal_snapshots (
+ tenant_id TEXT NOT NULL,
+ run_id UUID NOT NULL,
+ job_id UUID NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ kind TEXT NOT NULL CHECK (kind IN (
+ 'queued', 'started', 'phase', 'blocked',
+ 'failed', 'succeeded', 'canceled', 'unavailable'
+ )),
+ phase TEXT NOT NULL CHECK (phase IN (
+ 'resolve', 'fetch', 'restore', 'analyze',
+ 'policy', 'report', 'unknown'
+ )),
+ summary TEXT NOT NULL,
+ eta_seconds INT NULL,
+
+ last_known_outcome JSONB NULL,
+ next_actions JSONB NULL,
+ diagnostics JSONB NOT NULL DEFAULT '{}'::jsonb,
+ signal_json JSONB NOT NULL,
+
+ CONSTRAINT pk_ro_first_signal_snapshots PRIMARY KEY (tenant_id, run_id)
+);
+
+CREATE INDEX IF NOT EXISTS ix_ro_first_signal_snapshots_job
+ ON release_orchestrator.first_signal_snapshots (tenant_id, job_id);
+CREATE INDEX IF NOT EXISTS ix_ro_first_signal_snapshots_updated
+ ON release_orchestrator.first_signal_snapshots (tenant_id, updated_at DESC);
+
+-- ===================================================================
+-- SQL functions for audit hash chain
+-- ===================================================================
+
+-- Get next audit sequence number for a tenant
+CREATE OR REPLACE FUNCTION release_orchestrator.next_audit_sequence(
+ p_tenant_id TEXT
+) RETURNS TABLE (
+ next_seq BIGINT,
+ prev_hash TEXT
+) AS $$
+DECLARE
+ v_next_seq BIGINT;
+ v_prev_hash TEXT;
+BEGIN
+ INSERT INTO release_orchestrator.audit_sequences (tenant_id, last_sequence_number, last_entry_hash, updated_at)
+ VALUES (p_tenant_id, 1, NULL, NOW())
+ ON CONFLICT (tenant_id)
+ DO UPDATE SET
+ last_sequence_number = release_orchestrator.audit_sequences.last_sequence_number + 1,
+ updated_at = NOW()
+ RETURNING release_orchestrator.audit_sequences.last_sequence_number,
+ release_orchestrator.audit_sequences.last_entry_hash
+ INTO v_next_seq, v_prev_hash;
+
+ RETURN QUERY SELECT v_next_seq, v_prev_hash;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Update audit sequence hash after insertion
+CREATE OR REPLACE FUNCTION release_orchestrator.update_audit_sequence_hash(
+ p_tenant_id TEXT,
+ p_content_hash TEXT
+) RETURNS VOID AS $$
+BEGIN
+ UPDATE release_orchestrator.audit_sequences
+ SET last_entry_hash = p_content_hash,
+ updated_at = NOW()
+ WHERE tenant_id = p_tenant_id;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Verify audit chain integrity
+CREATE OR REPLACE FUNCTION release_orchestrator.verify_audit_chain(
+ p_tenant_id TEXT,
+ p_start_seq BIGINT DEFAULT 1,
+ p_end_seq BIGINT DEFAULT NULL
+) RETURNS TABLE (
+ is_valid BOOLEAN,
+ invalid_entry_id UUID,
+ invalid_sequence BIGINT,
+ error_message TEXT
+) AS $$
+DECLARE
+ v_prev_hash TEXT;
+ v_entry RECORD;
+BEGIN
+ FOR v_entry IN
+ SELECT ae.entry_id, ae.sequence_number, ae.previous_entry_hash, ae.content_hash
+ FROM release_orchestrator.audit_entries ae
+ WHERE ae.tenant_id = p_tenant_id
+ AND ae.sequence_number >= p_start_seq
+ AND (p_end_seq IS NULL OR ae.sequence_number <= p_end_seq)
+ ORDER BY ae.sequence_number ASC
+ LOOP
+ IF v_entry.sequence_number = 1 AND v_entry.previous_entry_hash IS NOT NULL THEN
+ RETURN QUERY SELECT FALSE, v_entry.entry_id, v_entry.sequence_number,
+ 'First entry should have null previous_entry_hash'::TEXT;
+ RETURN;
+ END IF;
+
+ IF v_prev_hash IS NOT NULL AND v_entry.previous_entry_hash != v_prev_hash THEN
+ RETURN QUERY SELECT FALSE, v_entry.entry_id, v_entry.sequence_number,
+ format('Chain break: expected %s, got %s', v_prev_hash, v_entry.previous_entry_hash);
+ RETURN;
+ END IF;
+
+ v_prev_hash := v_entry.content_hash;
+ END LOOP;
+
+ RETURN QUERY SELECT TRUE, NULL::UUID, NULL::BIGINT, NULL::TEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Get audit summary statistics
+CREATE OR REPLACE FUNCTION release_orchestrator.get_audit_summary(
+ p_tenant_id TEXT,
+ p_since TIMESTAMPTZ DEFAULT NULL
+) RETURNS TABLE (
+ total_entries BIGINT,
+ entries_since BIGINT,
+ event_types BIGINT,
+ unique_actors BIGINT,
+ unique_resources BIGINT,
+ earliest_entry TIMESTAMPTZ,
+ latest_entry TIMESTAMPTZ
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ COUNT(*)::BIGINT AS total_entries,
+ COUNT(*) FILTER (WHERE p_since IS NULL OR ae.occurred_at >= p_since)::BIGINT AS entries_since,
+ COUNT(DISTINCT ae.event_type)::BIGINT AS event_types,
+ COUNT(DISTINCT ae.actor_id)::BIGINT AS unique_actors,
+ COUNT(DISTINCT (ae.resource_type, ae.resource_id))::BIGINT AS unique_resources,
+ MIN(ae.occurred_at) AS earliest_entry,
+ MAX(ae.occurred_at) AS latest_entry
+ FROM release_orchestrator.audit_entries ae
+ WHERE ae.tenant_id = p_tenant_id;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Cleanup old audit entries (retention-aware)
+CREATE OR REPLACE FUNCTION release_orchestrator.cleanup_audit_entries(
+ p_retention_days INTEGER DEFAULT 365,
+ p_batch_limit INTEGER DEFAULT 10000
+) RETURNS INTEGER AS $$
+DECLARE
+ deleted_count INTEGER;
+BEGIN
+ WITH deleted AS (
+ DELETE FROM release_orchestrator.audit_entries
+ WHERE ctid IN (
+ SELECT ctid FROM release_orchestrator.audit_entries
+ WHERE occurred_at < NOW() - (p_retention_days || ' days')::INTERVAL
+ LIMIT p_batch_limit
+ )
+ RETURNING 1
+ )
+ SELECT COUNT(*) INTO deleted_count FROM deleted;
+
+ RETURN deleted_count;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Comments
+COMMENT ON TABLE release_orchestrator.audit_entries IS 'Immutable audit log with hash chain for tamper evidence';
+COMMENT ON TABLE release_orchestrator.audit_sequences IS 'Sequence tracking for audit entry chain integrity';
+COMMENT ON TABLE release_orchestrator.first_signal_snapshots IS 'Per-run cached first-signal payload for TTFS fast path';
+COMMENT ON FUNCTION release_orchestrator.verify_audit_chain IS 'Verifies the hash chain integrity of audit entries';
diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs
index 4d7c96441..1afcfc55d 100644
--- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs
+++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs
@@ -12,8 +12,10 @@ namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres;
///
public sealed class PostgresAuditRepository : IAuditRepository
{
+ private const string Schema = ReleaseOrchestratorDataSource.DefaultSchemaName;
+
private const string InsertEntrySql = """
- INSERT INTO audit_entries (
+ INSERT INTO release_orchestrator.audit_entries (
entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata)
@@ -24,28 +26,28 @@ public sealed class PostgresAuditRepository : IAuditRepository
""";
private const string GetSequenceSql = """
- SELECT next_seq, prev_hash FROM next_audit_sequence(@tenant_id)
+ SELECT next_seq, prev_hash FROM release_orchestrator.next_audit_sequence(@tenant_id)
""";
private const string UpdateSequenceHashSql = """
- SELECT update_audit_sequence_hash(@tenant_id, @content_hash)
+ SELECT release_orchestrator.update_audit_sequence_hash(@tenant_id, @content_hash)
""";
private const string VerifyChainSql = """
SELECT is_valid, invalid_entry_id, invalid_sequence, error_message
- FROM verify_audit_chain(@tenant_id, @start_seq, @end_seq)
+ FROM release_orchestrator.verify_audit_chain(@tenant_id, @start_seq, @end_seq)
""";
private const string GetSummarySql = """
SELECT total_entries, entries_since, event_types, unique_actors, unique_resources, earliest_entry, latest_entry
- FROM get_audit_summary(@tenant_id, @since)
+ FROM release_orchestrator.get_audit_summary(@tenant_id, @since)
""";
private const string GetByIdSql = """
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
- FROM audit_entries
+ FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id AND entry_id = @entry_id
""";
@@ -53,7 +55,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
- FROM audit_entries
+ FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id
""";
@@ -61,7 +63,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
- FROM audit_entries
+ FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id
ORDER BY sequence_number DESC
LIMIT 1
@@ -71,7 +73,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
- FROM audit_entries
+ FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id AND sequence_number >= @start_seq AND sequence_number <= @end_seq
ORDER BY sequence_number ASC
""";
@@ -80,7 +82,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
- FROM audit_entries
+ FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id AND resource_type = @resource_type AND resource_id = @resource_id
ORDER BY occurred_at DESC
LIMIT @limit
@@ -88,7 +90,7 @@ public sealed class PostgresAuditRepository : IAuditRepository
private const string GetCountSql = """
SELECT COUNT(*)
- FROM audit_entries
+ FROM release_orchestrator.audit_entries
WHERE tenant_id = @tenant_id
""";
diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs
new file mode 100644
index 000000000..0d44eda5b
--- /dev/null
+++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresFirstSignalSnapshotRepository.cs
@@ -0,0 +1,176 @@
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using NpgsqlTypes;
+using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
+
+namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres;
+
+///
+/// PostgreSQL implementation of first signal snapshot repository.
+/// Uses raw SQL (no EF Core) for a lean dependency footprint.
+///
+public sealed class PostgresFirstSignalSnapshotRepository : IFirstSignalSnapshotRepository
+{
+ private const string GetByRunIdSql = """
+ SELECT tenant_id, run_id, job_id, created_at, updated_at,
+ kind, phase, summary, eta_seconds,
+ last_known_outcome, next_actions, diagnostics, signal_json
+ FROM release_orchestrator.first_signal_snapshots
+ WHERE tenant_id = @tenant_id AND run_id = @run_id
+ """;
+
+ private const string DeleteByRunIdSql = """
+ DELETE FROM release_orchestrator.first_signal_snapshots
+ WHERE tenant_id = @tenant_id AND run_id = @run_id
+ """;
+
+ private const string UpsertSql = """
+ INSERT INTO release_orchestrator.first_signal_snapshots (
+ tenant_id, run_id, job_id, created_at, updated_at,
+ kind, phase, summary, eta_seconds,
+ last_known_outcome, next_actions, diagnostics, signal_json)
+ VALUES (
+ @tenant_id, @run_id, @job_id, @created_at, @updated_at,
+ @kind, @phase, @summary, @eta_seconds,
+ @last_known_outcome, @next_actions, @diagnostics, @signal_json)
+ ON CONFLICT (tenant_id, run_id) DO UPDATE SET
+ job_id = EXCLUDED.job_id,
+ updated_at = EXCLUDED.updated_at,
+ kind = EXCLUDED.kind,
+ phase = EXCLUDED.phase,
+ summary = EXCLUDED.summary,
+ eta_seconds = EXCLUDED.eta_seconds,
+ last_known_outcome = EXCLUDED.last_known_outcome,
+ next_actions = EXCLUDED.next_actions,
+ diagnostics = EXCLUDED.diagnostics,
+ signal_json = EXCLUDED.signal_json
+ """;
+
+ private readonly ReleaseOrchestratorDataSource _dataSource;
+ private readonly ILogger _logger;
+
+ public PostgresFirstSignalSnapshotRepository(
+ ReleaseOrchestratorDataSource dataSource,
+ ILogger logger)
+ {
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task GetByRunIdAsync(
+ string tenantId,
+ Guid runId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ if (runId == Guid.Empty)
+ {
+ throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
+ }
+
+ await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
+ await using var command = new NpgsqlCommand(GetByRunIdSql, connection);
+ command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("run_id", runId);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ return null;
+ }
+
+ return ReadSnapshot(reader);
+ }
+
+ public async Task UpsertAsync(
+ FirstSignalSnapshot snapshot,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ ArgumentException.ThrowIfNullOrWhiteSpace(snapshot.TenantId);
+ if (snapshot.RunId == Guid.Empty)
+ {
+ throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(snapshot));
+ }
+
+ await using var connection = await _dataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken).ConfigureAwait(false);
+ await using var command = new NpgsqlCommand(UpsertSql, connection);
+ command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
+
+ command.Parameters.AddWithValue("tenant_id", snapshot.TenantId);
+ command.Parameters.AddWithValue("run_id", snapshot.RunId);
+ command.Parameters.AddWithValue("job_id", snapshot.JobId);
+ command.Parameters.AddWithValue("created_at", snapshot.CreatedAt);
+ command.Parameters.AddWithValue("updated_at", snapshot.UpdatedAt);
+ command.Parameters.AddWithValue("kind", snapshot.Kind);
+ command.Parameters.AddWithValue("phase", snapshot.Phase);
+ command.Parameters.AddWithValue("summary", snapshot.Summary);
+ command.Parameters.AddWithValue("eta_seconds", (object?)snapshot.EtaSeconds ?? DBNull.Value);
+
+ command.Parameters.Add(new NpgsqlParameter("last_known_outcome", NpgsqlDbType.Jsonb)
+ {
+ Value = (object?)snapshot.LastKnownOutcomeJson ?? DBNull.Value
+ });
+ command.Parameters.Add(new NpgsqlParameter("next_actions", NpgsqlDbType.Jsonb)
+ {
+ Value = (object?)snapshot.NextActionsJson ?? DBNull.Value
+ });
+ command.Parameters.Add(new NpgsqlParameter("diagnostics", NpgsqlDbType.Jsonb)
+ {
+ Value = snapshot.DiagnosticsJson
+ });
+ command.Parameters.Add(new NpgsqlParameter("signal_json", NpgsqlDbType.Jsonb)
+ {
+ Value = snapshot.SignalJson
+ });
+
+ try
+ {
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (PostgresException ex)
+ {
+ _logger.LogError(ex, "Failed to upsert first signal snapshot for tenant {TenantId} run {RunId}.",
+ snapshot.TenantId, snapshot.RunId);
+ throw;
+ }
+ }
+
+ public async Task DeleteByRunIdAsync(
+ string tenantId,
+ Guid runId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ if (runId == Guid.Empty)
+ {
+ throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
+ }
+
+ await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
+ await using var command = new NpgsqlCommand(DeleteByRunIdSql, connection);
+ command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("run_id", runId);
+
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static FirstSignalSnapshot ReadSnapshot(NpgsqlDataReader reader) => new()
+ {
+ TenantId = reader.GetString(0),
+ RunId = reader.GetGuid(1),
+ JobId = reader.GetGuid(2),
+ CreatedAt = reader.GetFieldValue(3),
+ UpdatedAt = reader.GetFieldValue(4),
+ Kind = reader.GetString(5),
+ Phase = reader.GetString(6),
+ Summary = reader.GetString(7),
+ EtaSeconds = reader.IsDBNull(8) ? null : reader.GetInt32(8),
+ LastKnownOutcomeJson = reader.IsDBNull(9) ? null : reader.GetString(9),
+ NextActionsJson = reader.IsDBNull(10) ? null : reader.GetString(10),
+ DiagnosticsJson = reader.GetString(11),
+ SignalJson = reader.GetString(12)
+ };
+}
diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs
new file mode 100644
index 000000000..136b9c76e
--- /dev/null
+++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Repositories/IFirstSignalSnapshotRepository.cs
@@ -0,0 +1,43 @@
+namespace StellaOps.ReleaseOrchestrator.Persistence.Repositories;
+
+///
+/// Repository for first-signal snapshot persistence.
+///
+public interface IFirstSignalSnapshotRepository
+{
+ Task GetByRunIdAsync(
+ string tenantId,
+ Guid runId,
+ CancellationToken cancellationToken = default);
+
+ Task UpsertAsync(
+ FirstSignalSnapshot snapshot,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteByRunIdAsync(
+ string tenantId,
+ Guid runId,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// First-signal snapshot storage model.
+///
+public sealed record FirstSignalSnapshot
+{
+ public required string TenantId { get; init; }
+ public required Guid RunId { get; init; }
+ public required Guid JobId { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public required DateTimeOffset UpdatedAt { get; init; }
+
+ public required string Kind { get; init; }
+ public required string Phase { get; init; }
+ public required string Summary { get; init; }
+ public int? EtaSeconds { get; init; }
+
+ public string? LastKnownOutcomeJson { get; init; }
+ public string? NextActionsJson { get; init; }
+ public required string DiagnosticsJson { get; init; }
+ public required string SignalJson { get; init; }
+}
diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs
new file mode 100644
index 000000000..2b6886a51
--- /dev/null
+++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Services/FirstSignalService.cs
@@ -0,0 +1,238 @@
+using Microsoft.Extensions.Logging;
+using StellaOps.ReleaseOrchestrator.Persistence.Domain;
+using StellaOps.ReleaseOrchestrator.Persistence.Hashing;
+using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.ReleaseOrchestrator.Persistence.Services;
+
+///
+/// Snapshot-only first signal service for release-orchestrator.
+/// Unlike the JobEngine version, this service does not compute signals from runs/jobs --
+/// it only stores and retrieves snapshot data written by upstream producers.
+///
+public sealed class FirstSignalService : IFirstSignalService
+{
+ private static readonly JsonSerializerOptions SignalJsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
+ };
+
+ private readonly IFirstSignalSnapshotRepository _snapshotRepository;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public FirstSignalService(
+ IFirstSignalSnapshotRepository snapshotRepository,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task GetFirstSignalAsync(
+ Guid runId,
+ string tenantId,
+ string? ifNoneMatch = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (runId == Guid.Empty)
+ {
+ throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
+ }
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+
+ // Snapshot path (only path in the lean release-orchestrator service)
+ var snapshot = await _snapshotRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
+ if (snapshot is null)
+ {
+ return new FirstSignalResult
+ {
+ Status = FirstSignalResultStatus.NotFound,
+ CacheHit = false,
+ Source = null,
+ ETag = null,
+ Signal = null,
+ };
+ }
+
+ var signal = TryDeserializeSignal(snapshot.SignalJson);
+ if (signal is null)
+ {
+ _logger.LogWarning(
+ "Invalid first signal snapshot JSON for tenant {TenantId} run {RunId}; deleting snapshot row.",
+ tenantId, runId);
+
+ await _snapshotRepository.DeleteByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
+
+ return new FirstSignalResult
+ {
+ Status = FirstSignalResultStatus.NotFound,
+ CacheHit = false,
+ Source = null,
+ ETag = null,
+ Signal = null,
+ };
+ }
+
+ var etag = GenerateEtag(signal);
+ const string origin = "snapshot";
+
+ if (IsNotModified(ifNoneMatch, etag))
+ {
+ return new FirstSignalResult
+ {
+ Status = FirstSignalResultStatus.NotModified,
+ CacheHit = false,
+ Source = origin,
+ ETag = etag,
+ Signal = signal with
+ {
+ Diagnostics = signal.Diagnostics with
+ {
+ CacheHit = false,
+ Source = origin,
+ }
+ }
+ };
+ }
+
+ return new FirstSignalResult
+ {
+ Status = FirstSignalResultStatus.Found,
+ CacheHit = false,
+ Source = origin,
+ ETag = etag,
+ Signal = signal with
+ {
+ Diagnostics = signal.Diagnostics with
+ {
+ CacheHit = false,
+ Source = origin,
+ }
+ }
+ };
+ }
+
+ public async Task UpdateSnapshotAsync(
+ Guid runId,
+ string tenantId,
+ FirstSignal signal,
+ CancellationToken cancellationToken = default)
+ {
+ if (runId == Guid.Empty)
+ {
+ throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentNullException.ThrowIfNull(signal);
+
+ var now = _timeProvider.GetUtcNow();
+ var signalJson = CanonicalJsonHasher.ToCanonicalJson(signal);
+
+ var snapshot = new FirstSignalSnapshot
+ {
+ TenantId = tenantId,
+ RunId = runId,
+ JobId = signal.JobId,
+ CreatedAt = now,
+ UpdatedAt = now,
+ Kind = signal.Kind.ToString().ToLowerInvariant(),
+ Phase = signal.Phase.ToString().ToLowerInvariant(),
+ Summary = signal.Summary,
+ EtaSeconds = signal.EtaSeconds,
+ LastKnownOutcomeJson = signal.LastKnownOutcome is null
+ ? null
+ : JsonSerializer.Serialize(signal.LastKnownOutcome, SignalJsonOptions),
+ NextActionsJson = signal.NextActions is null
+ ? null
+ : JsonSerializer.Serialize(signal.NextActions, SignalJsonOptions),
+ DiagnosticsJson = JsonSerializer.Serialize(signal.Diagnostics, SignalJsonOptions),
+ SignalJson = signalJson,
+ };
+
+ await _snapshotRepository.UpsertAsync(snapshot, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task InvalidateCacheAsync(
+ Guid runId,
+ string tenantId,
+ CancellationToken cancellationToken = default)
+ {
+ if (runId == Guid.Empty)
+ {
+ throw new ArgumentException("Run ID must be a non-empty GUID.", nameof(runId));
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+
+ // In the lean service, invalidation means deleting the snapshot row.
+ await _snapshotRepository.DeleteByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static FirstSignal? TryDeserializeSignal(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, SignalJsonOptions);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static string GenerateEtag(FirstSignal signal)
+ {
+ var material = new
+ {
+ signal.Version,
+ signal.JobId,
+ signal.Timestamp,
+ signal.Kind,
+ signal.Phase,
+ signal.Scope,
+ signal.Summary,
+ signal.EtaSeconds,
+ signal.LastKnownOutcome,
+ signal.NextActions
+ };
+
+ var canonicalJson = CanonicalJsonHasher.ToCanonicalJson(material);
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
+ var base64 = Convert.ToBase64String(hash.AsSpan(0, 8));
+ return $"W/\"{base64}\"";
+ }
+
+ private static bool IsNotModified(string? ifNoneMatch, string etag)
+ {
+ if (string.IsNullOrWhiteSpace(ifNoneMatch))
+ {
+ return false;
+ }
+
+ var candidates = ifNoneMatch
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(t => t.Trim())
+ .ToList();
+
+ if (candidates.Any(t => t == "*"))
+ {
+ return true;
+ }
+
+ return candidates.Any(t => string.Equals(t, etag, StringComparison.Ordinal));
+ }
+}
diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json
index c9af42840..7c25e979d 100644
--- a/src/Router/StellaOps.Gateway.WebService/appsettings.json
+++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json
@@ -124,7 +124,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
{ "Type": "ReverseProxy", "Path": "^/api/v1/registries(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/registries$1", "PreserveAuthHeaders": true },
- { "Type": "Microservice", "Path": "^/api/v1/jobengine/registry/packs(.*)", "IsRegex": true, "TranslatesTo": "http://packsregistry.stella-ops.local/api/v1/jobengine/registry/packs$1" },
+ { "Type": "Microservice", "Path": "^/api/v1/jobengine/registry/packs(.*)", "IsRegex": true, "TranslatesTo": "http://packsregistry.stella-ops.local/api/v1/packs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/jobengine/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/deadletter(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/jobengine/deadletter$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/jobs(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/jobengine/jobs$1" },
diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs
new file mode 100644
index 000000000..e8518424c
--- /dev/null
+++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/TenantAwareCryptoProviderRegistryTests.cs
@@ -0,0 +1,410 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using StellaOps.Cryptography;
+using StellaOps.TestKit;
+using Xunit;
+
+namespace StellaOps.Cryptography.Tests;
+
+public class TenantAwareCryptoProviderRegistryTests
+{
+ private const string TenantA = "tenant-a";
+ private const string TenantB = "tenant-b";
+
+ private static readonly ILogger Logger = NullLoggerFactory.Instance.CreateLogger();
+
+ // ---------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------
+
+ private sealed class FakePreferenceProvider : ITenantCryptoPreferenceProvider
+ {
+ private readonly Dictionary> preferences = new(StringComparer.OrdinalIgnoreCase);
+ public int CallCount { get; private set; }
+
+ public void Set(string tenantId, string scope, IReadOnlyList providers)
+ {
+ preferences[$"{tenantId}:{scope}"] = providers;
+ }
+
+ public Task> GetPreferredProvidersAsync(
+ string tenantId, string algorithmScope = "*", CancellationToken cancellationToken = default)
+ {
+ CallCount++;
+ var key = $"{tenantId}:{algorithmScope}";
+ return Task.FromResult(
+ preferences.TryGetValue(key, out var list) ? list : (IReadOnlyList)Array.Empty());
+ }
+ }
+
+ private sealed class FakeProvider : ICryptoProvider
+ {
+ private readonly HashSet<(CryptoCapability, string)> supported = new();
+
+ public FakeProvider(string name) => Name = name;
+
+ public string Name { get; }
+
+ public FakeProvider WithSupport(CryptoCapability capability, string algorithm)
+ {
+ supported.Add((capability, algorithm));
+ return this;
+ }
+
+ public bool Supports(CryptoCapability capability, string algorithmId) => supported.Contains((capability, algorithmId));
+
+ public IPasswordHasher GetPasswordHasher(string algorithmId) => throw new NotSupportedException();
+
+ public ICryptoHasher GetHasher(string algorithmId) => new StubHasher(algorithmId);
+
+ public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
+ => new StubSigner(Name, keyReference.KeyId, algorithmId);
+
+ public void UpsertSigningKey(CryptoSigningKey signingKey) { }
+ public bool RemoveSigningKey(string keyId) => false;
+ public IReadOnlyCollection GetSigningKeys() => Array.Empty();
+ }
+
+ private sealed class StubHasher : ICryptoHasher
+ {
+ public StubHasher(string algorithmId) => AlgorithmId = algorithmId;
+ public string AlgorithmId { get; }
+ public byte[] ComputeHash(ReadOnlySpan data) => Array.Empty();
+ public string ComputeHashHex(ReadOnlySpan data) => "";
+ }
+
+ private sealed class StubSigner : ICryptoSigner
+ {
+ public StubSigner(string provider, string keyId, string algorithmId)
+ {
+ Provider = provider;
+ KeyId = keyId;
+ AlgorithmId = algorithmId;
+ }
+
+ public string Provider { get; }
+ public string KeyId { get; }
+ public string AlgorithmId { get; }
+ public ValueTask SignAsync(ReadOnlyMemory data, CancellationToken ct = default)
+ => ValueTask.FromResult(Array.Empty());
+ public ValueTask VerifyAsync(ReadOnlyMemory data, ReadOnlyMemory signature, CancellationToken ct = default)
+ => ValueTask.FromResult(true);
+ public Microsoft.IdentityModel.Tokens.JsonWebKey ExportPublicJsonWebKey() => new();
+ }
+
+ private static CryptoProviderRegistry BuildInnerRegistry(params FakeProvider[] providers)
+ {
+ return new CryptoProviderRegistry(providers);
+ }
+
+ private static TenantAwareCryptoProviderRegistry Build(
+ ICryptoProviderRegistry inner,
+ FakePreferenceProvider preferences,
+ Func tenantAccessor,
+ TimeSpan? cacheTtl = null)
+ {
+ return new TenantAwareCryptoProviderRegistry(
+ inner,
+ preferences,
+ tenantAccessor,
+ TimeProvider.System,
+ Logger,
+ cacheTtl ?? TimeSpan.FromMinutes(5));
+ }
+
+ // ---------------------------------------------------------------
+ // ResolveOrThrow
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveOrThrow_NoTenantContext_FallsBackToInnerRegistry()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ var registry = Build(inner, prefs, () => null);
+
+ var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ // No tenant context => inner registry default ordering => providerA (first registered)
+ Assert.Same(providerA, resolved);
+ Assert.Equal(0, prefs.CallCount); // Should not even query preferences
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveOrThrow_WithTenantPreference_ReturnsPreferredProvider()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerB" });
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ Assert.Same(providerB, resolved);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveOrThrow_TenantPreferenceProviderEmpty_FallsBackToDefault()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ // No preferences set for TenantA => empty list
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ // Empty preferences => fallback to inner default => providerA
+ Assert.Same(providerA, resolved);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveOrThrow_TenantAccessorThrows_FallsBackToDefault()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var inner = BuildInnerRegistry(providerA);
+ var prefs = new FakePreferenceProvider();
+
+ var registry = Build(inner, prefs, () => throw new InvalidOperationException("no tenant context"));
+
+ var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ Assert.Same(providerA, resolved);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveOrThrow_PreferredProviderDoesNotSupportAlgorithm_TriesNextThenFallback()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Rs256);
+ var providerC = new FakeProvider("providerC").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB, providerC);
+ var prefs = new FakePreferenceProvider();
+ // Tenant prefers providerB first, then providerC; providerB doesn't support Es256
+ prefs.Set(TenantA, "*", new[] { "providerB", "providerC" });
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ // providerB doesn't support Es256 => providerC does => resolved to providerC
+ Assert.Same(providerC, resolved);
+ }
+
+ // ---------------------------------------------------------------
+ // ResolveSigner
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveSigner_ExplicitPreferredProvider_OverridesTenantPreference()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerB" });
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ // Explicit preferredProvider "providerA" should override tenant preference "providerB"
+ var result = registry.ResolveSigner(
+ CryptoCapability.Signing,
+ SignatureAlgorithms.Es256,
+ new CryptoKeyReference("key-1"),
+ preferredProvider: "providerA");
+
+ Assert.Equal("providerA", result.ProviderName);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveSigner_NoExplicitPreferred_UsesTenantPreference()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerB" });
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ var result = registry.ResolveSigner(
+ CryptoCapability.Signing,
+ SignatureAlgorithms.Es256,
+ new CryptoKeyReference("key-1"));
+
+ Assert.Equal("providerB", result.ProviderName);
+ }
+
+ // ---------------------------------------------------------------
+ // ResolveHasher
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveHasher_NoTenantContext_FallsBackToDefault()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ var registry = Build(inner, prefs, () => null);
+
+ var result = registry.ResolveHasher(HashAlgorithms.Sha256);
+
+ Assert.Equal("providerA", result.ProviderName);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveHasher_WithTenantPreference_ReturnsPreferredProvider()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerB" });
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ var result = registry.ResolveHasher(HashAlgorithms.Sha256);
+
+ Assert.Equal("providerB", result.ProviderName);
+ }
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void ResolveHasher_ExplicitPreferred_OverridesTenantPreference()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerB" });
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ var result = registry.ResolveHasher(HashAlgorithms.Sha256, preferredProvider: "providerA");
+
+ Assert.Equal("providerA", result.ProviderName);
+ }
+
+ // ---------------------------------------------------------------
+ // Caching
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void PreferencesCached_SecondCallDoesNotQueryProvider()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerB" });
+
+ var registry = Build(inner, prefs, () => TenantA, cacheTtl: TimeSpan.FromMinutes(5));
+
+ // First call: cache miss -> hits provider
+ registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ Assert.Equal(1, prefs.CallCount);
+
+ // Second call: cache hit -> no additional provider query
+ registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ Assert.Equal(1, prefs.CallCount);
+ }
+
+ // ---------------------------------------------------------------
+ // Providers property delegates to inner
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void Providers_DelegatesToInnerRegistry()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var inner = BuildInnerRegistry(providerA);
+ var prefs = new FakePreferenceProvider();
+
+ var registry = Build(inner, prefs, () => null);
+
+ Assert.Single(registry.Providers);
+ Assert.Contains(registry.Providers, p => p.Name == "providerA");
+ }
+
+ // ---------------------------------------------------------------
+ // TryResolve delegates to inner
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void TryResolve_DelegatesToInnerRegistry()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var inner = BuildInnerRegistry(providerA);
+ var prefs = new FakePreferenceProvider();
+
+ var registry = Build(inner, prefs, () => TenantA);
+
+ Assert.True(registry.TryResolve("providerA", out var resolved));
+ Assert.Same(providerA, resolved);
+
+ Assert.False(registry.TryResolve("nonexistent", out _));
+ }
+
+ // ---------------------------------------------------------------
+ // Multi-tenant isolation
+ // ---------------------------------------------------------------
+
+ [Trait("Category", TestCategories.Unit)]
+ [Fact]
+ public void DifferentTenants_ResolveDifferentProviders()
+ {
+ var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+
+ var inner = BuildInnerRegistry(providerA, providerB);
+ var prefs = new FakePreferenceProvider();
+ prefs.Set(TenantA, "*", new[] { "providerA" });
+ prefs.Set(TenantB, "*", new[] { "providerB" });
+
+ string currentTenant = TenantA;
+ var registry = Build(inner, prefs, () => currentTenant);
+
+ // Tenant A resolves providerA
+ var resolvedA = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ Assert.Same(providerA, resolvedA);
+
+ // Switch to Tenant B: resolves providerB
+ currentTenant = TenantB;
+ var resolvedB = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
+ Assert.Same(providerB, resolvedB);
+ }
+}