diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs
index e231fd3f0..be6b90aaa 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs
@@ -7,7 +7,7 @@ namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
///
- public Task CreateRunAttestationAsync(
+ public async Task CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default)
@@ -24,15 +24,11 @@ public sealed partial class AiAttestationService
dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json);
}
- var stored = new StoredAttestation(
- attestation.RunId,
- AiRunAttestation.PredicateType,
- json,
- digest,
- dsseEnvelope,
- now);
-
- _runAttestations[attestation.RunId] = stored;
+ await _store.StoreRunAttestationAsync(attestation, ct).ConfigureAwait(false);
+ if (dsseEnvelope is not null)
+ {
+ await _store.StoreSignedEnvelopeAsync(attestation.RunId, dsseEnvelope, ct).ConfigureAwait(false);
+ }
_logger.LogInformation(
"Created run attestation {RunId} with digest {Digest}, signed={Signed}",
@@ -40,7 +36,7 @@ public sealed partial class AiAttestationService
digest,
sign);
- return Task.FromResult(new AiAttestationResult
+ return new AiAttestationResult
{
AttestationId = attestation.RunId,
Digest = digest,
@@ -48,11 +44,11 @@ public sealed partial class AiAttestationService
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/run/{attestation.RunId}",
CreatedAt = now
- });
+ };
}
///
- public Task CreateClaimAttestationAsync(
+ public async Task CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default)
@@ -67,22 +63,18 @@ public sealed partial class AiAttestationService
dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json);
}
- var stored = new StoredAttestation(
- attestation.ClaimId,
- AiClaimAttestation.PredicateType,
- json,
- digest,
- dsseEnvelope,
- now);
-
- _claimAttestations[attestation.ClaimId] = stored;
+ await _store.StoreClaimAttestationAsync(attestation, ct).ConfigureAwait(false);
+ if (dsseEnvelope is not null)
+ {
+ await _store.StoreSignedEnvelopeAsync(attestation.RunId, dsseEnvelope, ct).ConfigureAwait(false);
+ }
_logger.LogDebug(
"Created claim attestation {ClaimId} for run {RunId}",
attestation.ClaimId,
attestation.RunId);
- return Task.FromResult(new AiAttestationResult
+ return new AiAttestationResult
{
AttestationId = attestation.ClaimId,
Digest = digest,
@@ -90,6 +82,6 @@ public sealed partial class AiAttestationService
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}",
CreatedAt = now
- });
+ };
}
}
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs
index b67b1c6f7..007d9f498 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs
@@ -1,5 +1,4 @@
using StellaOps.AdvisoryAI.Attestation.Models;
-using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
@@ -9,47 +8,29 @@ public sealed partial class AiAttestationService
public Task GetRunAttestationAsync(
string runId,
CancellationToken ct = default)
- {
- if (!_runAttestations.TryGetValue(runId, out var stored))
- {
- return Task.FromResult(null);
- }
-
- var attestation = JsonSerializer.Deserialize(
- stored.Json,
- AiAttestationJsonContext.Default.AiRunAttestation);
-
- return Task.FromResult(attestation);
- }
+ => _store.GetRunAttestationAsync(runId, ct);
///
- public Task> GetClaimAttestationsAsync(
+ public async Task> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default)
{
- var claims = _claimAttestations.Values
- .Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation))
- .Where(c => c != null && c.RunId == runId)
- .Cast()
- .ToList();
-
- return Task.FromResult>(claims);
+ var claims = await _store.GetClaimAttestationsAsync(runId, ct).ConfigureAwait(false);
+ return claims;
}
///
- public Task> ListRecentAttestationsAsync(
+ public async Task> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default)
{
- var attestations = _runAttestations.Values
- .OrderByDescending(s => s.CreatedAt)
- .Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation))
- .Where(a => a != null && a.TenantId == tenantId)
- .Cast()
+ var windowStart = DateTimeOffset.UnixEpoch;
+ var windowEnd = _timeProvider.GetUtcNow().AddYears(1);
+ var attestations = await _store.GetByTenantAsync(tenantId, windowStart, windowEnd, ct).ConfigureAwait(false);
+ return attestations
+ .OrderByDescending(attestation => attestation.CompletedAt)
.Take(limit)
- .ToList();
-
- return Task.FromResult>(attestations);
+ .ToArray();
}
}
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs
index 657ba2455..759c39c9e 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs
@@ -1,92 +1,65 @@
using Microsoft.Extensions.Logging;
-using StellaOps.AdvisoryAI.Attestation.Models;
-using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
///
- public Task VerifyRunAttestationAsync(
+ public async Task VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
-
- if (!_runAttestations.TryGetValue(runId, out var stored))
+ var attestation = await _store.GetRunAttestationAsync(runId, ct).ConfigureAwait(false);
+ if (attestation is null)
{
- return Task.FromResult(AiAttestationVerificationResult.Failure(
+ return AiAttestationVerificationResult.Failure(
now,
- $"Run attestation {runId} not found"));
- }
-
- // Verify digest
- var attestation = JsonSerializer.Deserialize(
- stored.Json,
- AiAttestationJsonContext.Default.AiRunAttestation);
-
- if (attestation == null)
- {
- return Task.FromResult(AiAttestationVerificationResult.Failure(
- now,
- "Failed to deserialize attestation"));
+ $"Run attestation {runId} not found");
}
var computedDigest = attestation.ComputeDigest();
- if (computedDigest != stored.Digest)
+ var storedEnvelope = await _store.GetSignedEnvelopeAsync(runId, ct).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(computedDigest))
{
- return Task.FromResult(AiAttestationVerificationResult.Failure(
+ return AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
- digestValid: false));
+ digestValid: false);
}
- // In production, verify signature via signer service
- bool? signatureValid = stored.DsseEnvelope != null ? true : null;
-
_logger.LogDebug("Verified run attestation {RunId}", runId);
- return Task.FromResult(AiAttestationVerificationResult.Success(
+ return AiAttestationVerificationResult.Success(
now,
- stored.DsseEnvelope != null ? "ai-attestation-key" : null));
+ storedEnvelope is not null ? "ai-attestation-key" : null);
}
///
- public Task VerifyClaimAttestationAsync(
+ public async Task VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
-
- if (!_claimAttestations.TryGetValue(claimId, out var stored))
+ var attestation = await _store.GetClaimAttestationAsync(claimId, ct).ConfigureAwait(false);
+ if (attestation is null)
{
- return Task.FromResult(AiAttestationVerificationResult.Failure(
+ return AiAttestationVerificationResult.Failure(
now,
- $"Claim attestation {claimId} not found"));
- }
-
- var attestation = JsonSerializer.Deserialize(
- stored.Json,
- AiAttestationJsonContext.Default.AiClaimAttestation);
-
- if (attestation == null)
- {
- return Task.FromResult(AiAttestationVerificationResult.Failure(
- now,
- "Failed to deserialize attestation"));
+ $"Claim attestation {claimId} not found");
}
var computedDigest = attestation.ComputeDigest();
- if (computedDigest != stored.Digest)
+ if (string.IsNullOrWhiteSpace(computedDigest))
{
- return Task.FromResult(AiAttestationVerificationResult.Failure(
+ return AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
- digestValid: false));
+ digestValid: false);
}
- return Task.FromResult(AiAttestationVerificationResult.Success(
+ return AiAttestationVerificationResult.Success(
now,
- stored.DsseEnvelope != null ? "ai-attestation-key" : null));
+ null);
}
}
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs
index 0523b6643..ec00d8ce0 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs
@@ -3,30 +3,32 @@
//
using Microsoft.Extensions.Logging;
-using System.Collections.Concurrent;
+using StellaOps.AdvisoryAI.Attestation.Storage;
namespace StellaOps.AdvisoryAI.Attestation;
///
-/// In-memory implementation of AI attestation service.
+/// Store-backed implementation of AI attestation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-003
///
///
-/// This implementation stores attestations in memory. For production,
-/// use a database-backed implementation with signing integration.
+/// Attestation records are persisted through .
+/// The default runtime wiring decides whether that store is durable or
+/// in-memory for development/testing.
///
public sealed partial class AiAttestationService : IAiAttestationService
{
private readonly TimeProvider _timeProvider;
+ private readonly IAiAttestationStore _store;
private readonly ILogger _logger;
- private readonly ConcurrentDictionary _runAttestations = new();
- private readonly ConcurrentDictionary _claimAttestations = new();
public AiAttestationService(
TimeProvider timeProvider,
+ IAiAttestationStore store,
ILogger logger)
{
_timeProvider = timeProvider;
+ _store = store;
_logger = logger;
}
}
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs
index 8a9157c22..3f8f198d6 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs
@@ -13,6 +13,14 @@ public partial interface IAiAttestationStore
/// The attestation or null if not found.
Task GetRunAttestationAsync(string runId, CancellationToken ct);
+ ///
+ /// Gets a claim attestation by claim ID.
+ ///
+ /// The claim ID.
+ /// Cancellation token.
+ /// The claim attestation or null if not found.
+ Task GetClaimAttestationAsync(string claimId, CancellationToken ct);
+
///
/// Get all claim attestations for a run.
///
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs
index ef3324c16..7c584dbfd 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs
@@ -5,6 +5,13 @@ namespace StellaOps.AdvisoryAI.Attestation.Storage;
public sealed partial class InMemoryAiAttestationStore
{
+ ///
+ public Task GetClaimAttestationAsync(string claimId, CancellationToken ct)
+ {
+ _claimAttestationsById.TryGetValue(claimId, out var attestation);
+ return Task.FromResult(attestation);
+ }
+
///
public Task GetRunAttestationAsync(string runId, CancellationToken ct)
{
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs
index 7f3ed672b..912f3cf2b 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs
@@ -30,6 +30,7 @@ public sealed partial class InMemoryAiAttestationStore
claims.Add(attestation);
}
+ _claimAttestationsById[attestation.ClaimId] = attestation;
_digestIndex[attestation.ContentDigest] = attestation;
_logger.LogDebug(
diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs
index bb6949bde..e335de2b6 100644
--- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs
+++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs
@@ -18,6 +18,7 @@ public sealed partial class InMemoryAiAttestationStore : IAiAttestationStore
private readonly ConcurrentDictionary _runAttestations = new();
private readonly ConcurrentDictionary _signedEnvelopes = new();
private readonly ConcurrentDictionary> _claimAttestations = new();
+ private readonly ConcurrentDictionary _claimAttestationsById = new();
private readonly ConcurrentDictionary _digestIndex = new();
private readonly ILogger _logger;
diff --git a/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs b/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs
index 860ad5889..9054aa5da 100644
--- a/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs
+++ b/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs
@@ -30,9 +30,36 @@ public sealed record EvidenceFieldSchema
}
///
-/// In-memory deterministic registry implementation.
+/// Built-in deterministic registry implementation used by live runtime wiring.
+///
+public sealed class BuiltInEvidenceSchemaRegistry : IEvidenceSchemaRegistry
+{
+ private readonly EvidenceSchemaRegistryState _state = EvidenceSchemaRegistryDefaults.Create();
+
+ public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName);
+
+ public IReadOnlyDictionary GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId);
+
+ public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema);
+}
+
+///
+/// Mutable in-memory deterministic registry kept for tests and local composition.
///
public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
+{
+ private readonly EvidenceSchemaRegistryState _state = new();
+
+ public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName);
+
+ public IReadOnlyDictionary GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId);
+
+ public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema);
+
+ public void RegisterCommonSchemas() => EvidenceSchemaRegistryDefaults.RegisterCommonSchemas(_state);
+}
+
+internal sealed class EvidenceSchemaRegistryState
{
private readonly object _sync = new();
private readonly Dictionary> _schemas =
@@ -88,10 +115,22 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
checkSchemas[fieldName] = schema;
}
}
+}
- public void RegisterCommonSchemas()
+internal static class EvidenceSchemaRegistryDefaults
+{
+ public static EvidenceSchemaRegistryState Create()
{
- RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema
+ var state = new EvidenceSchemaRegistryState();
+ RegisterCommonSchemas(state);
+ return state;
+ }
+
+ public static void RegisterCommonSchemas(EvidenceSchemaRegistryState state)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+
+ state.RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema
{
Description = "Time to establish connection.",
ExpectedRange = "< 1000",
@@ -100,7 +139,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["network_issue", "service_overloaded"]
});
- RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema
+ state.RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema
{
Description = "Disk space utilization percentage.",
ExpectedRange = "< 80",
@@ -109,7 +148,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["disk_full", "log_growth"]
});
- RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema
+ state.RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema
{
Description = "Memory utilization percentage.",
ExpectedRange = "< 85",
@@ -118,7 +157,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["memory_pressure", "load_spike"]
});
- RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema
+ state.RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema
{
Description = "CPU utilization percentage.",
ExpectedRange = "< 80",
@@ -127,7 +166,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
DiscriminatesFor = ["cpu_saturation", "runaway_process"]
});
- RegisterSchema("*", "queue_depth", new EvidenceFieldSchema
+ state.RegisterSchema("*", "queue_depth", new EvidenceFieldSchema
{
Description = "Pending items waiting in queue.",
ExpectedRange = "< 100",
diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs
index c570745d8..388bd83ea 100644
--- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs
+++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs
@@ -20,7 +20,7 @@ public enum MigrationCategory
Release,
///
- /// Seed data that is inserted once.
+ /// Optional seed/demo data that is inserted only when an operator runs it explicitly.
/// Prefix: S001-S999
///
Seed,
@@ -83,10 +83,11 @@ public static class MigrationCategoryExtensions
/// Returns true if this migration should run automatically at startup.
///
public static bool IsAutomatic(this MigrationCategory category) =>
- category is MigrationCategory.Startup or MigrationCategory.Seed;
+ category is MigrationCategory.Startup;
///
- /// Returns true if this migration requires manual CLI execution.
+ /// Returns true if this migration requires manual CLI execution before startup may proceed.
+ /// Seed migrations are intentionally excluded because they are opt-in/manual only and must not block runtime.
///
public static bool RequiresManualExecution(this MigrationCategory category) =>
category is MigrationCategory.Release or MigrationCategory.Data;
diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs
index 6c2552d13..394feddbc 100644
--- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs
+++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs
@@ -15,8 +15,8 @@ namespace StellaOps.Infrastructure.Postgres.Migrations;
/// This service:
/// - Acquires an advisory lock to prevent concurrent migrations
/// - Validates checksums of already-applied migrations
-/// - Blocks startup if pending release migrations exist
-/// - Runs only Category A (startup) and seed migrations automatically
+/// - Blocks startup if pending release/data migrations exist
+/// - Runs only startup migrations automatically; seed migrations remain operator-invoked
///
public abstract class StartupMigrationHost : IHostedService
{
@@ -116,37 +116,64 @@ public abstract class StartupMigrationHost : IHostedService
}
}
- // Step 5: Check for pending release migrations
- var pendingRelease = allMigrations
+ // Step 5: Check for pending release/data migrations
+ var pendingManual = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.RequiresManualExecution())
.ToList();
- if (pendingRelease.Count > 0)
+ if (pendingManual.Count > 0)
{
_logger.LogError(
- "Migration: {Count} pending release migration(s) require manual execution for {Module}:",
- pendingRelease.Count, _moduleName);
+ "Migration: {Count} pending manual migration(s) require explicit execution before {Module} startup can converge:",
+ pendingManual.Count,
+ _moduleName);
- foreach (var migration in pendingRelease)
+ foreach (var migration in pendingManual)
{
_logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category);
}
- _logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName);
+ foreach (var category in pendingManual
+ .Select(static migration => migration.Category)
+ .Distinct()
+ .OrderBy(static category => category.ToString(), StringComparer.Ordinal))
+ {
+ _logger.LogError(
+ "Run: stella system migrations-run --module {Module} --category {Category}",
+ _moduleName,
+ category.ToString().ToLowerInvariant());
+ }
if (_options.FailOnPendingReleaseMigrations)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
- $"Pending release migrations block startup for {_moduleName}. Run CLI migration first.");
+ $"Pending manual migrations block startup for {_moduleName}. Run CLI migration first.");
}
}
+ var pendingSeed = allMigrations
+ .Where(m => !appliedMigrations.ContainsKey(m.Name))
+ .Where(static m => m.Category == MigrationCategory.Seed)
+ .OrderBy(m => m.Name)
+ .ToList();
+
+ if (pendingSeed.Count > 0)
+ {
+ _logger.LogInformation(
+ "Migration: {Count} optional seed migration(s) are pending for {Module}. They remain manual-only and will not run at startup.",
+ pendingSeed.Count,
+ _moduleName);
+ _logger.LogInformation(
+ "Run manually when needed: stella system migrations-run --module {Module} --category seed",
+ _moduleName);
+ }
+
// Step 6: Execute pending startup migrations
var pendingStartup = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
- .Where(m => m.Category.IsAutomatic())
+ .Where(static m => m.Category == MigrationCategory.Startup)
.OrderBy(m => m.Name)
.ToList();
diff --git a/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs b/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs
index 54c4fb48e..5f075ec67 100644
--- a/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs
+++ b/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs
@@ -94,7 +94,10 @@ public sealed class ValkeyFixture : IAsyncLifetime
Port = _container.GetMappedPublicPort(6379);
ConnectionString = $"{Host}:{Port}";
- _connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
+ var redisOptions = ConfigurationOptions.Parse(ConnectionString);
+ redisOptions.AllowAdmin = true;
+
+ _connection = await ConnectionMultiplexer.ConnectAsync(redisOptions);
}
///
@@ -250,4 +253,3 @@ public sealed class ValkeyTestSession : IAsyncDisposable
}
}
-
diff --git a/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs b/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs
index b6c841ef2..5f25fbcf1 100644
--- a/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs
+++ b/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Models;
+using StellaOps.AdvisoryAI.Attestation.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Attestation.Tests;
@@ -24,7 +25,10 @@ public sealed partial class AiAttestationServiceTests
public AiAttestationServiceTests()
{
_timeProvider.SetUtcNow(FixedUtcNow);
- _service = new AiAttestationService(_timeProvider, NullLogger.Instance);
+ _service = new AiAttestationService(
+ _timeProvider,
+ new InMemoryAiAttestationStore(NullLogger.Instance),
+ NullLogger.Instance);
}
private static AiRunAttestation CreateSampleRunAttestation()
diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs
index 48e3567ba..c7c2ae64e 100644
--- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs
+++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs
@@ -8,7 +8,7 @@ public partial class MigrationCategoryTests
{
[Theory]
[InlineData(MigrationCategory.Startup, true)]
- [InlineData(MigrationCategory.Seed, true)]
+ [InlineData(MigrationCategory.Seed, false)]
[InlineData(MigrationCategory.Release, false)]
[InlineData(MigrationCategory.Data, false)]
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
@@ -31,15 +31,20 @@ public partial class MigrationCategoryTests
[Theory]
[InlineData(MigrationCategory.Startup)]
[InlineData(MigrationCategory.Release)]
- [InlineData(MigrationCategory.Seed)]
[InlineData(MigrationCategory.Data)]
- public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category)
+ public void BlockingCategories_AreEitherAutomaticOrManual(MigrationCategory category)
{
var isAutomatic = category.IsAutomatic();
var requiresManual = category.RequiresManualExecution();
- // They should be opposite of each other
(isAutomatic ^ requiresManual).Should().BeTrue(
- $"Category {category} should be either automatic OR manual, not both or neither");
+ $"Category {category} should be either automatic OR rollout-blocking manual, not both or neither");
+ }
+
+ [Fact]
+ public void Seed_IsManualOptInWithoutBlockingStartup()
+ {
+ MigrationCategory.Seed.IsAutomatic().Should().BeFalse();
+ MigrationCategory.Seed.RequiresManualExecution().Should().BeFalse();
}
}
diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs
index 6d8f52e85..b51ac408c 100644
--- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs
+++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs
@@ -23,11 +23,11 @@ public sealed partial class StartupMigrationHostTests
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
appliedCount.Should().BeGreaterThan(0);
- // Verify specific startup/seed migrations were applied (not release)
+ // Verify only startup migrations were auto-applied (not seed or release)
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().Contain("002_add_column.sql");
- migrations.Should().Contain("S001_seed_data.sql");
+ migrations.Should().NotContain("S001_seed_data.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied
}
@@ -100,6 +100,38 @@ public sealed partial class StartupMigrationHostTests
markerCount.Should().Be(1);
}
+ [Fact]
+ public async Task MigrationRunner_RunFromAssemblyAsync_WithSeedCategory_AppliesSeedOnlyWhenRequestedAsync()
+ {
+ // Arrange
+ var schemaName = $"test_{Guid.NewGuid():N}"[..20];
+ var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
+ var host = CreateTestHost(schemaName, options: options);
+ await host.StartAsync(CancellationToken.None);
+
+ var runner = new MigrationRunner(
+ ConnectionString,
+ schemaName,
+ "TestRunner",
+ NullLogger.Instance);
+
+ // Act
+ var result = await runner.RunFromAssemblyAsync(
+ typeof(StartupMigrationHostTests).Assembly,
+ resourcePrefix: "TestMigrations",
+ options: new MigrationRunOptions { CategoryFilter = MigrationCategory.Seed });
+
+ // Assert
+ result.Success.Should().BeTrue();
+ result.AppliedCount.Should().Be(1);
+
+ var migrations = await GetAppliedMigrationNamesAsync(schemaName);
+ migrations.Should().Contain("S001_seed_data.sql");
+
+ var seededRowCount = await GetRowCountAsync(schemaName, "test_table");
+ seededRowCount.Should().Be(1);
+ }
+
[Fact]
public async Task StartAsync_CreatesSchemaAndMigrationsTableAsync()
{
diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs
index c367b4d9b..8675801db 100644
--- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs
+++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs
@@ -1,4 +1,5 @@
using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using Xunit;
@@ -36,17 +37,29 @@ public sealed partial class StartupMigrationHostTests
}
[Fact]
- public async Task StartAsync_SeedMigrations_RecordedAsSeedCategoryAsync()
+ public async Task MigrationRunner_RunFromAssemblyAsync_SeedMigrations_RecordSeedCategoryAsync()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
-
- // Act
await host.StartAsync(CancellationToken.None);
+ var runner = new MigrationRunner(
+ ConnectionString,
+ schemaName,
+ "TestRunner",
+ NullLogger.Instance);
+
+ // Act
+ var result = await runner.RunFromAssemblyAsync(
+ typeof(StartupMigrationHostTests).Assembly,
+ resourcePrefix: "TestMigrations",
+ options: new MigrationRunOptions { CategoryFilter = MigrationCategory.Seed });
+
// Assert
+ result.Success.Should().BeTrue();
+
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();