This commit is contained in:
StellaOps Bot
2025-12-09 00:20:52 +02:00
parent 3d01bf9edc
commit bc0762e97d
261 changed files with 14033 additions and 4427 deletions

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Findings.Ledger.Options;
using StellaOps.Findings.Ledger.Services.Incident;
using StellaOps.Findings.Ledger.Tests.Observability;
using StellaOps.Telemetry.Core;
using Xunit;
namespace StellaOps.Findings.Ledger.Tests.Incident;
public class LedgerIncidentCoordinatorTests
{
[Fact]
public async Task Activation_updates_state_and_notifies()
{
var options = Options.Create(new LedgerIncidentOptions { RetentionExtensionDays = 45, LagTraceThresholdSeconds = 0.0 });
var logger = new TestLogger<LedgerIncidentCoordinator>();
var notifier = new TestNotifier();
var incidentService = new StubIncidentModeService();
using var coordinator = new LedgerIncidentCoordinator(options, logger, notifier, TimeProvider.System, incidentService);
await incidentService.ActivateAsync("actor-a", reason: "test");
coordinator.IsActive.Should().BeTrue();
coordinator.Current.RetentionExtensionDays.Should().Be(45);
notifier.Published.Should().ContainSingle();
logger.Entries.Should().ContainSingle(e => e.EventId.Id == 6901);
}
[Fact]
public async Task RecordProjectionLag_emits_when_active_and_above_threshold()
{
var options = Options.Create(new LedgerIncidentOptions { LagTraceThresholdSeconds = 0.1, RetentionExtensionDays = 5 });
var logger = new TestLogger<LedgerIncidentCoordinator>();
var notifier = new TestNotifier();
var incidentService = new StubIncidentModeService();
using var coordinator = new LedgerIncidentCoordinator(options, logger, notifier, TimeProvider.System, incidentService);
await incidentService.ActivateAsync("actor-a");
coordinator.RecordProjectionLag(new ProjectionLagSample(
"tenant-a",
Guid.NewGuid(),
10,
"finding.created",
"v1",
5.0,
DateTimeOffset.UtcNow.AddSeconds(-5),
DateTimeOffset.UtcNow));
logger.Entries.Should().Contain(e => e.EventId.Id == 6902);
coordinator.GetDiagnosticsSnapshot().LagSamples.Should().NotBeEmpty();
}
private sealed class TestNotifier : ILedgerIncidentNotifier
{
private readonly List<LedgerIncidentSnapshot> _published = new();
public IReadOnlyList<LedgerIncidentSnapshot> Published => _published;
public Task PublishIncidentModeChangedAsync(LedgerIncidentSnapshot snapshot, CancellationToken cancellationToken)
{
_published.Add(snapshot);
return Task.CompletedTask;
}
}
private sealed class StubIncidentModeService : IIncidentModeService
{
private IncidentModeState? _state;
public bool IsActive => _state is { Enabled: true } && !_state.IsExpired;
public IncidentModeState? CurrentState => _state;
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
public event EventHandler<IncidentModeDeactivatedEventArgs>? Deactivated;
public Task<IncidentModeActivationResult> ActivateAsync(string actor, string? tenantId = null, TimeSpan? ttlOverride = null, string? reason = null, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
_state = new IncidentModeState
{
Enabled = true,
ActivatedAt = now,
ExpiresAt = now.AddMinutes(30),
Actor = actor,
TenantId = tenantId,
Source = IncidentModeSource.Api,
Reason = reason,
ActivationId = Guid.NewGuid().ToString("N")[..12]
};
Activated?.Invoke(this, new IncidentModeActivatedEventArgs { State = _state, WasReactivation = false });
return Task.FromResult(IncidentModeActivationResult.Succeeded(_state));
}
public Task<IncidentModeDeactivationResult> DeactivateAsync(string actor, string? reason = null, CancellationToken ct = default)
{
var previous = _state;
_state = null;
if (previous is not null)
{
Deactivated?.Invoke(this, new IncidentModeDeactivatedEventArgs
{
State = previous,
Reason = IncidentModeDeactivationReason.Manual,
DeactivatedBy = actor
});
}
return Task.FromResult(IncidentModeDeactivationResult.Succeeded(previous is not null, IncidentModeDeactivationReason.Manual));
}
public Task<DateTimeOffset?> ExtendTtlAsync(TimeSpan extension, string actor, CancellationToken ct = default) =>
Task.FromResult<DateTimeOffset?>(_state?.ExpiresAt?.Add(extension));
public IReadOnlyDictionary<string, string> GetIncidentTags() => new Dictionary<string, string>();
}
}

View File

@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Services.Incident;
using Xunit;
namespace StellaOps.Findings.Ledger.Tests.Observability;
@@ -45,6 +46,49 @@ public class LedgerTimelineTests
state["Status"].Should().Be("affected");
}
[Fact]
public void EmitIncidentModeChanged_writes_structured_log()
{
var logger = new TestLogger<LedgerTimelineTests>();
var snapshot = new LedgerIncidentSnapshot(
IsActive: true,
ActivationId: "act-123",
Actor: "actor-1",
Reason: "reason",
TenantId: "tenant-a",
ChangedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.AddMinutes(10),
RetentionExtensionDays: 30);
LedgerTimeline.EmitIncidentModeChanged(logger, snapshot, wasReactivation: false);
var entry = logger.Entries.Single(e => e.EventId.Id == 6901);
var state = AsDictionary(entry.State);
state["RetentionExtensionDays"].Should().Be(30);
state["ActivationId"].Should().Be("act-123");
}
[Fact]
public void EmitIncidentLagTrace_writes_structured_log()
{
var logger = new TestLogger<LedgerTimelineTests>();
var sample = new ProjectionLagSample(
"tenant-a",
Guid.NewGuid(),
10,
"finding.created",
"v1",
12.5,
DateTimeOffset.UtcNow.AddSeconds(-12),
DateTimeOffset.UtcNow);
LedgerTimeline.EmitIncidentLagTrace(logger, sample);
var entry = logger.Entries.Single(e => e.EventId.Id == 6902);
var state = AsDictionary(entry.State);
state["LagSeconds"].Should().Be(12.5);
}
private static LedgerEventRecord CreateRecord()
{
var payload = new JsonObject { ["status"] = "affected" };

View File

@@ -0,0 +1,81 @@
using System;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.Services.Incident;
using Xunit;
namespace StellaOps.Findings.Ledger.Tests.Services;
public class LedgerEventWriteServiceIncidentTests
{
[Fact]
public async Task AppendAsync_sequence_mismatch_records_conflict_snapshot()
{
var repo = new Mock<ILedgerEventRepository>();
repo.Setup(r => r.GetByEventIdAsync(It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((LedgerEventRecord?)null);
var chainId = Guid.NewGuid();
var chainHead = new LedgerEventRecord(
"tenant-a",
chainId,
1,
Guid.NewGuid(),
LedgerEventConstants.EventFindingCreated,
"v1",
"finding-1",
"artifact-1",
null,
"actor-1",
"operator",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
new JsonObject(),
"hash-prev",
LedgerEventConstants.EmptyHash,
"leaf-hash",
"{}");
repo.Setup(r => r.GetChainHeadAsync("tenant-a", chainId, It.IsAny<CancellationToken>()))
.ReturnsAsync(chainHead);
var scheduler = new Mock<IMerkleAnchorScheduler>();
var diagnostics = new Mock<ILedgerIncidentDiagnostics>();
var service = new LedgerEventWriteService(
repo.Object,
scheduler.Object,
NullLogger<LedgerEventWriteService>.Instance,
diagnostics.Object);
var draft = new LedgerEventDraft(
"tenant-a",
chainId,
3,
Guid.NewGuid(),
LedgerEventConstants.EventFindingCreated,
"v1",
"finding-1",
"artifact-1",
null,
"actor-1",
"operator",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
new JsonObject(),
new JsonObject(),
null);
var result = await service.AppendAsync(draft, CancellationToken.None);
result.Status.Should().Be(LedgerWriteStatus.Conflict);
diagnostics.Verify(d => d.RecordConflict(It.Is<ConflictSnapshot>(s => s.Reason == "sequence_mismatch")), Times.Once);
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.Services.Incident;
using Xunit;
public class SnapshotServiceTests
@@ -58,6 +59,33 @@ public class SnapshotServiceTests
Assert.True(result.Snapshot.ExpiresAt > DateTimeOffset.UtcNow);
}
[Fact]
public async Task CreateSnapshotAsync_WhenIncidentActive_ExtendsRetention()
{
var incident = new StaticIncidentDiagnostics(new LedgerIncidentSnapshot(
IsActive: true,
ActivationId: "act-1",
Actor: "actor",
Reason: "reason",
TenantId: "tenant-incident",
ChangedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
RetentionExtensionDays: 7));
var service = new SnapshotService(
_snapshotRepository,
_timeTravelRepository,
NullLogger<SnapshotService>.Instance,
incident);
var result = await service.CreateSnapshotAsync(
new CreateSnapshotInput("tenant-incident", Label: "incident-snapshot", ExpiresIn: TimeSpan.FromDays(1)));
Assert.NotNull(result.Snapshot);
Assert.True(result.Snapshot!.ExpiresAt >= DateTimeOffset.UtcNow.AddDays(1));
Assert.Equal("enabled", result.Snapshot.Metadata?["incident.mode"]);
}
[Fact]
public async Task GetSnapshotAsync_ReturnsExistingSnapshot()
{
@@ -371,3 +399,34 @@ internal class InMemoryTimeTravelRepository : ITimeTravelRepository
TimeSpan.FromMinutes(5)));
}
}
internal sealed class StaticIncidentDiagnostics : ILedgerIncidentDiagnostics
{
public StaticIncidentDiagnostics(LedgerIncidentSnapshot current)
{
Current = current;
}
public bool IsActive => Current.IsActive;
public LedgerIncidentSnapshot Current { get; }
public IncidentDiagnosticsSnapshot GetDiagnosticsSnapshot() => new(
Current,
Array.Empty<ProjectionLagSample>(),
Array.Empty<ConflictSnapshot>(),
Array.Empty<ReplayTraceSample>(),
DateTimeOffset.UtcNow);
public void RecordConflict(ConflictSnapshot snapshot)
{
}
public void RecordProjectionLag(ProjectionLagSample sample)
{
}
public void RecordReplayTrace(ReplayTraceSample sample)
{
}
}

View File

@@ -26,6 +26,10 @@ using StellaOps.Findings.Ledger.WebService.Services;
using StellaOps.Telemetry.Core;
using StellaOps.Findings.Ledger.Services.Security;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.OpenApi;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Findings.Ledger.Services.Incident;
const string LedgerWritePolicy = "ledger.events.write";
const string LedgerExportPolicy = "ledger.export.read";
@@ -62,6 +66,11 @@ builder.Services.AddOptions<LedgerServiceOptions>()
.PostConfigure(options => options.Validate())
.ValidateOnStart();
builder.Services.AddOptions<LedgerIncidentOptions>()
.Bind(builder.Configuration.GetSection(LedgerIncidentOptions.SectionName))
.PostConfigure(options => options.Validate())
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
@@ -80,6 +89,8 @@ builder.Services.AddStellaOpsTelemetry(
tracerBuilder.AddHttpClientInstrumentation();
});
builder.Services.AddIncidentMode(builder.Configuration);
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
@@ -130,6 +141,10 @@ builder.Services.AddAuthorization(options =>
});
});
builder.Services.AddSingleton<ILedgerIncidentNotifier, LoggingLedgerIncidentNotifier>();
builder.Services.AddSingleton<LedgerIncidentCoordinator>();
builder.Services.AddSingleton<ILedgerIncidentDiagnostics>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
builder.Services.AddSingleton<ILedgerIncidentState>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
builder.Services.AddSingleton<LedgerAnchorQueue>();
builder.Services.AddSingleton<LedgerDataSource>();
builder.Services.AddSingleton<IMerkleAnchorRepository, PostgresMerkleAnchorRepository>();
@@ -232,6 +247,8 @@ app.MapGet("/ledger/export/findings", async Task<Results<FileStreamHttpResult, J
ExportQueryService exportQueryService,
CancellationToken cancellationToken) =>
{
DeprecationHeaders.Apply(httpContext.Response, "ledger.export.findings");
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant", detail: "X-Stella-Tenant header is required.");
@@ -841,20 +858,40 @@ app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Tas
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/.well-known/openapi", () =>
app.MapGet("/.well-known/openapi", async (HttpContext context) =>
{
var contentRoot = AppContext.BaseDirectory;
var candidate = Path.GetFullPath(Path.Combine(contentRoot, "../../docs/modules/findings-ledger/openapi/findings-ledger.v1.yaml"));
if (!File.Exists(candidate))
var specPath = OpenApiMetadataFactory.GetSpecPath(contentRoot);
if (!File.Exists(specPath))
{
return Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: "openapi_missing", detail: "OpenAPI document not found on server.");
}
var yaml = File.ReadAllText(candidate);
return Results.Text(yaml, "application/yaml");
var specBytes = await File.ReadAllBytesAsync(specPath, context.RequestAborted).ConfigureAwait(false);
var etag = OpenApiMetadataFactory.ComputeEtag(specBytes);
if (context.Request.Headers.IfNoneMatch.Any(match => string.Equals(match, etag, StringComparison.Ordinal)))
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=300, must-revalidate";
context.Response.Headers.Append("X-Api-Version", OpenApiMetadataFactory.ApiVersion);
context.Response.Headers.Append("X-Build-Version", OpenApiMetadataFactory.GetBuildVersion());
var lastModified = OpenApiMetadataFactory.GetLastModified(specPath);
if (lastModified.HasValue)
{
context.Response.Headers.LastModified = lastModified.Value.ToString("R");
}
return Results.Text(Encoding.UTF8.GetString(specBytes), "application/yaml");
})
.WithName("LedgerOpenApiDocument")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status304NotModified)
.ProducesProblem(StatusCodes.Status500InternalServerError);
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Findings.Ledger;
/// <summary>
/// Applies standardized deprecation/notification headers to retiring endpoints.
/// </summary>
public static class DeprecationHeaders
{
private const string DeprecationLink =
"</.well-known/openapi>; rel=\"deprecation\"; type=\"application/yaml\"";
public const string SunsetDate = "2026-03-31T00:00:00Z";
public static void Apply(HttpResponse response, string endpointId)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentException.ThrowIfNullOrWhiteSpace(endpointId);
response.Headers["Deprecation"] = "true";
response.Headers["Sunset"] = SunsetDate;
response.Headers["X-Deprecated-Endpoint"] = endpointId;
response.Headers.Append("Link", DeprecationLink);
}
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
namespace StellaOps.Findings.Ledger.Domain;
@@ -18,7 +19,12 @@ public sealed record FindingProjection(
string? ExplainRef,
JsonArray PolicyRationale,
DateTimeOffset UpdatedAt,
string CycleHash);
string CycleHash,
int AttestationCount = 0,
int VerifiedAttestationCount = 0,
int FailedAttestationCount = 0,
int UnverifiedAttestationCount = 0,
OverallVerificationStatus AttestationStatus = OverallVerificationStatus.NoAttestations);
public sealed record FindingHistoryEntry(
string TenantId,

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
/// <summary>
/// Computes overall attestation status from summary counts.
/// </summary>
public static class AttestationStatusCalculator
{
public static OverallVerificationStatus Compute(int attestationCount, int verifiedCount)
{
if (attestationCount <= 0)
{
return OverallVerificationStatus.NoAttestations;
}
if (verifiedCount == attestationCount)
{
return OverallVerificationStatus.AllVerified;
}
if (verifiedCount > 0)
{
return OverallVerificationStatus.PartiallyVerified;
}
return OverallVerificationStatus.NoneVerified;
}
}

View File

@@ -1,8 +1,10 @@
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
using StellaOps.Findings.Ledger.Hashing;
using StellaOps.Findings.Ledger.Services;
@@ -11,23 +13,43 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepository
{
private const string GetProjectionSql = """
SELECT status,
severity,
risk_score,
risk_severity,
risk_profile_version,
risk_explanation_id,
risk_event_sequence,
labels,
current_event_id,
explain_ref,
policy_rationale,
updated_at,
cycle_hash
FROM findings_projection
WHERE tenant_id = @tenant_id
AND finding_id = @finding_id
AND policy_version = @policy_version
WITH attestation_summary AS (
SELECT
COUNT(*) AS attestation_count,
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
AND (verification_result->>'verified')::boolean = true) AS verified_count,
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
AND (verification_result->>'verified')::boolean = false) AS failed_count,
COUNT(*) FILTER (WHERE verification_result IS NULL) AS unverified_count
FROM ledger_attestation_pointers ap
WHERE ap.tenant_id = @tenant_id
AND ap.finding_id = @finding_id
)
SELECT fp.tenant_id,
fp.finding_id,
fp.policy_version,
fp.status,
fp.severity,
fp.risk_score,
fp.risk_severity,
fp.risk_profile_version,
fp.risk_explanation_id,
fp.risk_event_sequence,
fp.labels,
fp.current_event_id,
fp.explain_ref,
fp.policy_rationale,
fp.updated_at,
fp.cycle_hash,
COALESCE(a.attestation_count, 0) AS attestation_count,
COALESCE(a.verified_count, 0) AS verified_count,
COALESCE(a.failed_count, 0) AS failed_count,
COALESCE(a.unverified_count, 0) AS unverified_count
FROM findings_projection fp
LEFT JOIN attestation_summary a ON TRUE
WHERE fp.tenant_id = @tenant_id
AND fp.finding_id = @finding_id
AND fp.policy_version = @policy_version
""";
private const string UpsertProjectionSql = """
@@ -203,47 +225,7 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
return null;
}
var status = reader.GetString(0);
var severity = reader.IsDBNull(1) ? (decimal?)null : reader.GetDecimal(1);
var riskScore = reader.IsDBNull(2) ? (decimal?)null : reader.GetDecimal(2);
var riskSeverity = reader.IsDBNull(3) ? null : reader.GetString(3);
var riskProfileVersion = reader.IsDBNull(4) ? null : reader.GetString(4);
var riskExplanationId = reader.IsDBNull(5) ? (Guid?)null : reader.GetGuid(5);
var riskEventSequence = reader.IsDBNull(6) ? (long?)null : reader.GetInt64(6);
var labelsJson = reader.GetFieldValue<string>(7);
var labels = JsonNode.Parse(labelsJson)?.AsObject() ?? new JsonObject();
var currentEventId = reader.GetGuid(8);
var explainRef = reader.IsDBNull(9) ? null : reader.GetString(9);
var rationaleJson = reader.IsDBNull(10) ? string.Empty : reader.GetFieldValue<string>(10);
JsonArray rationale;
if (string.IsNullOrWhiteSpace(rationaleJson))
{
rationale = new JsonArray();
}
else
{
rationale = JsonNode.Parse(rationaleJson) as JsonArray ?? new JsonArray();
}
var updatedAt = reader.GetFieldValue<DateTimeOffset>(11);
var cycleHash = reader.GetString(12);
return new FindingProjection(
tenantId,
findingId,
policyVersion,
status,
severity,
riskScore,
riskSeverity,
riskProfileVersion,
riskExplanationId,
riskEventSequence,
labels,
currentEventId,
explainRef,
rationale,
updatedAt,
cycleHash);
return MapProjection(reader);
}
public async Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken)
@@ -407,7 +389,7 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
await using var connection = await _dataSource.OpenConnectionAsync(query.TenantId, "projector", cancellationToken).ConfigureAwait(false);
// Build dynamic query
var whereConditions = new List<string> { "tenant_id = @tenant_id" };
var whereConditions = new List<string> { "fp.tenant_id = @tenant_id" };
var parameters = new List<NpgsqlParameter>
{
new NpgsqlParameter<string>("tenant_id", query.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
@@ -415,34 +397,86 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
if (!string.IsNullOrWhiteSpace(query.PolicyVersion))
{
whereConditions.Add("policy_version = @policy_version");
whereConditions.Add("fp.policy_version = @policy_version");
parameters.Add(new NpgsqlParameter<string>("policy_version", query.PolicyVersion) { NpgsqlDbType = NpgsqlDbType.Text });
}
if (query.MinScore.HasValue)
{
whereConditions.Add("risk_score >= @min_score");
whereConditions.Add("fp.risk_score >= @min_score");
parameters.Add(new NpgsqlParameter<decimal>("min_score", query.MinScore.Value) { NpgsqlDbType = NpgsqlDbType.Numeric });
}
if (query.MaxScore.HasValue)
{
whereConditions.Add("risk_score <= @max_score");
whereConditions.Add("fp.risk_score <= @max_score");
parameters.Add(new NpgsqlParameter<decimal>("max_score", query.MaxScore.Value) { NpgsqlDbType = NpgsqlDbType.Numeric });
}
if (query.Severities is { Count: > 0 })
{
whereConditions.Add("risk_severity = ANY(@severities)");
whereConditions.Add("fp.risk_severity = ANY(@severities)");
parameters.Add(new NpgsqlParameter("severities", query.Severities.ToArray()) { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text });
}
if (query.Statuses is { Count: > 0 })
{
whereConditions.Add("status = ANY(@statuses)");
whereConditions.Add("fp.status = ANY(@statuses)");
parameters.Add(new NpgsqlParameter("statuses", query.Statuses.ToArray()) { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text });
}
if (query.AttestationTypes is { Count: > 0 })
{
parameters.Add(new NpgsqlParameter("attestation_types", query.AttestationTypes.Select(t => t.ToString()).ToArray())
{
NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text
});
}
var attestationWhere = new List<string>();
if (query.AttestationVerification.HasValue &&
query.AttestationVerification.Value != AttestationVerificationFilter.Any)
{
var filter = query.AttestationVerification.Value switch
{
AttestationVerificationFilter.Verified => "verified_count > 0",
AttestationVerificationFilter.Unverified => "unverified_count > 0",
AttestationVerificationFilter.Failed => "failed_count > 0",
_ => string.Empty
};
if (!string.IsNullOrWhiteSpace(filter))
{
attestationWhere.Add(filter);
}
}
if (query.AttestationStatus.HasValue)
{
var statusFilter = query.AttestationStatus.Value switch
{
OverallVerificationStatus.AllVerified =>
"attestation_count > 0 AND verified_count = attestation_count",
OverallVerificationStatus.PartiallyVerified =>
"attestation_count > 0 AND verified_count > 0 AND verified_count < attestation_count",
OverallVerificationStatus.NoneVerified =>
"attestation_count > 0 AND verified_count = 0",
OverallVerificationStatus.NoAttestations =>
"attestation_count = 0",
_ => string.Empty
};
if (!string.IsNullOrWhiteSpace(statusFilter))
{
attestationWhere.Add(statusFilter);
}
}
var attestationWhereClause = attestationWhere.Count > 0
? "WHERE " + string.Join(" AND ", attestationWhere)
: string.Empty;
var whereClause = string.Join(" AND ", whereConditions);
var orderColumn = query.SortBy switch
{
@@ -454,8 +488,46 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
};
var orderDirection = query.Descending ? "DESC NULLS LAST" : "ASC NULLS FIRST";
var attestationSummarySql = new StringBuilder(@"
SELECT tenant_id,
finding_id,
COUNT(*) AS attestation_count,
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
AND (verification_result->>'verified')::boolean = true) AS verified_count,
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
AND (verification_result->>'verified')::boolean = false) AS failed_count,
COUNT(*) FILTER (WHERE verification_result IS NULL) AS unverified_count
FROM ledger_attestation_pointers
WHERE tenant_id = @tenant_id");
if (query.AttestationTypes is { Count: > 0 })
{
attestationSummarySql.Append(" AND attestation_type = ANY(@attestation_types)");
}
attestationSummarySql.Append(" GROUP BY tenant_id, finding_id");
var cte = $@"
WITH attestation_summary AS (
{attestationSummarySql}
),
filtered_projection AS (
SELECT
fp.tenant_id, fp.finding_id, fp.policy_version, fp.status, fp.severity, fp.risk_score, fp.risk_severity,
fp.risk_profile_version, fp.risk_explanation_id, fp.risk_event_sequence, fp.labels, fp.current_event_id,
fp.explain_ref, fp.policy_rationale, fp.updated_at, fp.cycle_hash,
COALESCE(a.attestation_count, 0) AS attestation_count,
COALESCE(a.verified_count, 0) AS verified_count,
COALESCE(a.failed_count, 0) AS failed_count,
COALESCE(a.unverified_count, 0) AS unverified_count
FROM findings_projection fp
LEFT JOIN attestation_summary a
ON a.tenant_id = fp.tenant_id AND a.finding_id = fp.finding_id
WHERE {whereClause}
)";
// Count query
var countSql = $"SELECT COUNT(*) FROM findings_projection WHERE {whereClause}";
var countSql = $"{cte} SELECT COUNT(*) FROM filtered_projection {attestationWhereClause};";
await using var countCommand = new NpgsqlCommand(countSql, connection);
countCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
foreach (var p in parameters) countCommand.Parameters.Add(p.Clone());
@@ -463,12 +535,14 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
// Data query
var dataSql = $@"
{cte}
SELECT
tenant_id, finding_id, policy_version, status, severity, risk_score, risk_severity,
risk_profile_version, risk_explanation_id, risk_event_sequence, labels, current_event_id,
explain_ref, policy_rationale, updated_at, cycle_hash
FROM findings_projection
WHERE {whereClause}
explain_ref, policy_rationale, updated_at, cycle_hash,
attestation_count, verified_count, failed_count, unverified_count
FROM filtered_projection
{attestationWhereClause}
ORDER BY {orderColumn} {orderDirection}
LIMIT @limit";
@@ -638,6 +712,12 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
var rationaleJson = reader.GetString(13);
var rationale = System.Text.Json.Nodes.JsonNode.Parse(rationaleJson) as System.Text.Json.Nodes.JsonArray ?? new System.Text.Json.Nodes.JsonArray();
var attestationCount = reader.GetInt32(16);
var verifiedCount = reader.GetInt32(17);
var failedCount = reader.GetInt32(18);
var unverifiedCount = reader.GetInt32(19);
var attestationStatus = AttestationStatusCalculator.Compute(attestationCount, verifiedCount);
return new FindingProjection(
TenantId: reader.GetString(0),
FindingId: reader.GetString(1),
@@ -654,6 +734,11 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
ExplainRef: reader.IsDBNull(12) ? null : reader.GetString(12),
PolicyRationale: rationale,
UpdatedAt: reader.GetDateTime(14),
CycleHash: reader.GetString(15));
CycleHash: reader.GetString(15),
AttestationCount: attestationCount,
VerifiedAttestationCount: verifiedCount,
FailedAttestationCount: failedCount,
UnverifiedAttestationCount: unverifiedCount,
AttestationStatus: attestationStatus);
}
}

View File

@@ -8,6 +8,7 @@ using StellaOps.Findings.Ledger.Infrastructure.Policy;
using StellaOps.Findings.Ledger.Options;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Services;
using StellaOps.Findings.Ledger.Services.Incident;
namespace StellaOps.Findings.Ledger.Infrastructure.Projection;
@@ -19,6 +20,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
private readonly TimeProvider _timeProvider;
private readonly LedgerServiceOptions.ProjectionOptions _options;
private readonly ILogger<LedgerProjectionWorker> _logger;
private readonly ILedgerIncidentDiagnostics? _incidentDiagnostics;
public LedgerProjectionWorker(
ILedgerEventStream eventStream,
@@ -26,7 +28,8 @@ public sealed class LedgerProjectionWorker : BackgroundService
IPolicyEvaluationService policyEvaluationService,
IOptions<LedgerServiceOptions> options,
TimeProvider timeProvider,
ILogger<LedgerProjectionWorker> logger)
ILogger<LedgerProjectionWorker> logger,
ILedgerIncidentDiagnostics? incidentDiagnostics = null)
{
_eventStream = eventStream ?? throw new ArgumentNullException(nameof(eventStream));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
@@ -34,6 +37,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Projection;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_incidentDiagnostics = incidentDiagnostics;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -138,6 +142,15 @@ public sealed class LedgerProjectionWorker : BackgroundService
record.PolicyVersion,
evaluationStatus ?? string.Empty);
LedgerTimeline.EmitProjectionUpdated(_logger, record, evaluationStatus, evidenceBundleRef: null);
_incidentDiagnostics?.RecordProjectionLag(new ProjectionLagSample(
TenantId: record.TenantId,
ChainId: record.ChainId,
SequenceNumber: record.SequenceNumber,
EventType: record.EventType,
PolicyVersion: record.PolicyVersion,
LagSeconds: lagSeconds,
RecordedAt: record.RecordedAt,
ObservedAt: now));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{

View File

@@ -2,6 +2,7 @@ using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Exports;
using StellaOps.Findings.Ledger.Services.Incident;
namespace StellaOps.Findings.Ledger.Observability;
@@ -23,6 +24,10 @@ internal static class LedgerTimeline
private static readonly EventId TimeTravelQueryEvent = new(6803, "ledger.timetravel.query");
private static readonly EventId ReplayCompletedEvent = new(6804, "ledger.replay.completed");
private static readonly EventId DiffComputedEvent = new(6805, "ledger.diff.computed");
private static readonly EventId IncidentModeChangedEvent = new(6901, "ledger.incident.mode");
private static readonly EventId IncidentLagTraceEvent = new(6902, "ledger.incident.lag_trace");
private static readonly EventId IncidentConflictSnapshotEvent = new(6903, "ledger.incident.conflict_snapshot");
private static readonly EventId IncidentReplayTraceEvent = new(6904, "ledger.incident.replay_trace");
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
{
@@ -280,4 +285,87 @@ internal static class LedgerTimeline
modified,
removed);
}
public static void EmitIncidentModeChanged(ILogger logger, LedgerIncidentSnapshot snapshot, bool wasReactivation)
{
if (logger is null)
{
return;
}
logger.LogInformation(
IncidentModeChangedEvent,
"timeline ledger.incident.mode state={State} activation_id={ActivationId} actor={Actor} reason={Reason} expires_at={ExpiresAt} retention_extension_days={RetentionExtensionDays} reactivation={Reactivation}",
snapshot.IsActive ? "enabled" : "disabled",
snapshot.ActivationId ?? string.Empty,
snapshot.Actor ?? string.Empty,
snapshot.Reason ?? string.Empty,
snapshot.ExpiresAt?.ToString("O") ?? string.Empty,
snapshot.RetentionExtensionDays,
wasReactivation);
}
public static void EmitIncidentLagTrace(ILogger logger, ProjectionLagSample sample)
{
if (logger is null)
{
return;
}
logger.LogWarning(
IncidentLagTraceEvent,
"timeline ledger.incident.lag_trace tenant={Tenant} chain={ChainId} seq={Sequence} event_type={EventType} policy={PolicyVersion} lag_seconds={LagSeconds:0.000} recorded_at={RecordedAt} observed_at={ObservedAt}",
sample.TenantId,
sample.ChainId,
sample.SequenceNumber,
sample.EventType,
sample.PolicyVersion,
sample.LagSeconds,
sample.RecordedAt.ToString("O"),
sample.ObservedAt.ToString("O"));
}
public static void EmitIncidentConflictSnapshot(ILogger logger, ConflictSnapshot snapshot)
{
if (logger is null)
{
return;
}
logger.LogWarning(
IncidentConflictSnapshotEvent,
"timeline ledger.incident.conflict_snapshot tenant={Tenant} chain={ChainId} seq={Sequence} event_id={EventId} event_type={EventType} policy={PolicyVersion} reason={Reason} expected_seq={ExpectedSequence} actor={Actor} actor_type={ActorType} observed_at={ObservedAt}",
snapshot.TenantId,
snapshot.ChainId,
snapshot.SequenceNumber,
snapshot.EventId,
snapshot.EventType,
snapshot.PolicyVersion,
snapshot.Reason,
snapshot.ExpectedSequence,
snapshot.ActorId ?? string.Empty,
snapshot.ActorType ?? string.Empty,
snapshot.ObservedAt.ToString("O"));
}
public static void EmitIncidentReplayTrace(ILogger logger, ReplayTraceSample sample)
{
if (logger is null)
{
return;
}
logger.LogInformation(
IncidentReplayTraceEvent,
"timeline ledger.incident.replay_trace tenant={Tenant} from_seq={FromSequence} to_seq={ToSequence} events={Events} duration_ms={DurationMs} has_more={HasMore} chain_filters={ChainFilters} event_type_filters={EventTypeFilters} observed_at={ObservedAt}",
sample.TenantId,
sample.FromSequence,
sample.ToSequence,
sample.EventsCount,
sample.DurationMs,
sample.HasMore,
sample.ChainFilterCount,
sample.EventTypeFilterCount,
sample.ObservedAt.ToString("O"));
}
}

View File

@@ -0,0 +1,55 @@
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Findings.Ledger.OpenApi;
/// <summary>
/// Provides versioned metadata for the Findings Ledger OpenAPI discovery endpoint.
/// </summary>
public static class OpenApiMetadataFactory
{
public const string ApiVersion = "1.0.0-beta1";
public static string GetBuildVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var informational = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
return string.IsNullOrWhiteSpace(informational)
? assembly.GetName().Version?.ToString() ?? "0.0.0"
: informational;
}
public static string GetSpecPath(string contentRoot)
{
var current = Path.GetFullPath(contentRoot);
for (var i = 0; i < 10; i++)
{
var candidate = Path.Combine(current, "docs", "modules", "findings-ledger", "openapi", "findings-ledger.v1.yaml");
if (File.Exists(candidate))
{
return candidate;
}
current = Path.GetFullPath(Path.Combine(current, ".."));
}
// Fallback to previous behavior if traversal fails
return Path.GetFullPath(Path.Combine(contentRoot, "../../docs/modules/findings-ledger/openapi/findings-ledger.v1.yaml"));
}
public static DateTimeOffset? GetLastModified(string specPath)
{
return File.Exists(specPath)
? File.GetLastWriteTimeUtc(specPath)
: null;
}
public static string ComputeEtag(byte[] content)
{
var hash = SHA256.HashData(content);
var shortHash = Convert.ToHexString(hash)[..16].ToLowerInvariant();
return $"W/\"{shortHash}\"";
}
}

View File

@@ -0,0 +1,92 @@
using System;
namespace StellaOps.Findings.Ledger.Options;
/// <summary>
/// Configures incident-mode behaviour for the Findings Ledger.
/// </summary>
public sealed class LedgerIncidentOptions
{
public const string SectionName = "findings:ledger:incident";
/// <summary>
/// Enables ledger-side incident instrumentation.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Number of days to extend retention windows while incident mode is active.
/// </summary>
public int RetentionExtensionDays { get; set; } = 60;
/// <summary>
/// Minimum projection lag (seconds) that will be recorded during incident mode.
/// </summary>
public double LagTraceThresholdSeconds { get; set; } = 15;
/// <summary>
/// Maximum number of projection lag samples to retain.
/// </summary>
public int LagTraceBufferSize { get; set; } = 100;
/// <summary>
/// Maximum number of conflict snapshots to retain.
/// </summary>
public int ConflictSnapshotBufferSize { get; set; } = 50;
/// <summary>
/// Maximum number of replay traces to retain.
/// </summary>
public int ReplayTraceBufferSize { get; set; } = 50;
/// <summary>
/// Enables capture of projection lag traces when incident mode is active.
/// </summary>
public bool CaptureLagTraces { get; set; } = true;
/// <summary>
/// Enables capture of conflict snapshots when incident mode is active.
/// </summary>
public bool CaptureConflictSnapshots { get; set; } = true;
/// <summary>
/// Enables capture of replay request traces when incident mode is active.
/// </summary>
public bool CaptureReplayTraces { get; set; } = true;
/// <summary>
/// Whether to emit structured timeline/log entries for incident actions.
/// </summary>
public bool EmitTimelineEvents { get; set; } = true;
/// <summary>
/// Whether to emit notifier events (logging by default) for incident actions.
/// </summary>
public bool EmitNotifications { get; set; } = true;
/// <summary>
/// Clears buffered diagnostics on each activation to avoid mixing epochs.
/// </summary>
public bool ResetDiagnosticsOnActivation { get; set; } = true;
/// <summary>
/// Validates option values.
/// </summary>
public void Validate()
{
if (RetentionExtensionDays < 0 || RetentionExtensionDays > 3650)
{
throw new InvalidOperationException("RetentionExtensionDays must be between 0 and 3650.");
}
if (LagTraceThresholdSeconds < 0)
{
throw new InvalidOperationException("LagTraceThresholdSeconds must be non-negative.");
}
if (LagTraceBufferSize <= 0 || ConflictSnapshotBufferSize <= 0 || ReplayTraceBufferSize <= 0)
{
throw new InvalidOperationException("Incident diagnostic buffer sizes must be positive.");
}
}
}

View File

@@ -0,0 +1,355 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Options;
using StellaOps.Telemetry.Core;
namespace StellaOps.Findings.Ledger.Services.Incident;
public interface ILedgerIncidentDiagnostics : ILedgerIncidentState
{
void RecordProjectionLag(ProjectionLagSample sample);
void RecordConflict(ConflictSnapshot snapshot);
void RecordReplayTrace(ReplayTraceSample sample);
IncidentDiagnosticsSnapshot GetDiagnosticsSnapshot();
}
public interface ILedgerIncidentState
{
bool IsActive { get; }
LedgerIncidentSnapshot Current { get; }
}
public interface ILedgerIncidentNotifier
{
Task PublishIncidentModeChangedAsync(LedgerIncidentSnapshot snapshot, CancellationToken cancellationToken);
}
public sealed class LoggingLedgerIncidentNotifier : ILedgerIncidentNotifier
{
private readonly ILogger<LoggingLedgerIncidentNotifier> _logger;
public LoggingLedgerIncidentNotifier(ILogger<LoggingLedgerIncidentNotifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishIncidentModeChangedAsync(LedgerIncidentSnapshot snapshot, CancellationToken cancellationToken)
{
var state = snapshot.IsActive ? "enabled" : "disabled";
_logger.LogWarning(
"NOTIFICATION: Ledger incident mode {State} (activation_id={ActivationId}, retention_extension_days={ExtensionDays})",
state,
snapshot.ActivationId ?? string.Empty,
snapshot.RetentionExtensionDays);
return Task.CompletedTask;
}
}
public sealed record LedgerIncidentSnapshot(
bool IsActive,
string? ActivationId,
string? Actor,
string? Reason,
string? TenantId,
DateTimeOffset ChangedAt,
DateTimeOffset? ExpiresAt,
int RetentionExtensionDays);
public sealed record ProjectionLagSample(
string TenantId,
Guid ChainId,
long SequenceNumber,
string EventType,
string PolicyVersion,
double LagSeconds,
DateTimeOffset RecordedAt,
DateTimeOffset ObservedAt);
public sealed record ConflictSnapshot(
string TenantId,
Guid ChainId,
long SequenceNumber,
Guid EventId,
string EventType,
string PolicyVersion,
string Reason,
DateTimeOffset RecordedAt,
DateTimeOffset ObservedAt,
string? ActorId,
string? ActorType,
long ExpectedSequence,
string? ProvidedPreviousHash,
string? ExpectedPreviousHash);
public sealed record ReplayTraceSample(
string TenantId,
long FromSequence,
long ToSequence,
long EventsCount,
bool HasMore,
double DurationMs,
DateTimeOffset ObservedAt,
int ChainFilterCount,
int EventTypeFilterCount);
public sealed record IncidentDiagnosticsSnapshot(
LedgerIncidentSnapshot Incident,
IReadOnlyList<ProjectionLagSample> LagSamples,
IReadOnlyList<ConflictSnapshot> ConflictSnapshots,
IReadOnlyList<ReplayTraceSample> ReplayTraces,
DateTimeOffset CapturedAt);
/// <summary>
/// Coordinates ledger-specific incident mode behaviour (diagnostics, retention hints, timeline/notification events).
/// </summary>
public sealed class LedgerIncidentCoordinator : ILedgerIncidentDiagnostics, IDisposable
{
private const int ReplayTraceLogThresholdMs = 250;
private readonly LedgerIncidentOptions _options;
private readonly ILogger<LedgerIncidentCoordinator> _logger;
private readonly ILedgerIncidentNotifier _notifier;
private readonly TimeProvider _timeProvider;
private readonly IIncidentModeService? _incidentModeService;
private readonly ConcurrentQueue<ProjectionLagSample> _lagSamples = new();
private readonly ConcurrentQueue<ConflictSnapshot> _conflictSnapshots = new();
private readonly ConcurrentQueue<ReplayTraceSample> _replayTraces = new();
private readonly ConcurrentDictionary<string, DateTimeOffset> _lastLagLogByChain = new(StringComparer.Ordinal);
private readonly object _stateLock = new();
private LedgerIncidentSnapshot _current;
private bool _disposed;
public LedgerIncidentCoordinator(
IOptions<LedgerIncidentOptions> options,
ILogger<LedgerIncidentCoordinator> logger,
ILedgerIncidentNotifier notifier,
TimeProvider? timeProvider = null,
IIncidentModeService? incidentModeService = null)
{
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_options.Validate();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_notifier = notifier ?? throw new ArgumentNullException(nameof(notifier));
_timeProvider = timeProvider ?? TimeProvider.System;
_incidentModeService = incidentModeService;
_current = new LedgerIncidentSnapshot(
IsActive: false,
ActivationId: null,
Actor: null,
Reason: null,
TenantId: null,
ChangedAt: _timeProvider.GetUtcNow(),
ExpiresAt: null,
RetentionExtensionDays: 0);
if (_incidentModeService is not null)
{
_incidentModeService.Activated += OnActivated;
_incidentModeService.Deactivated += OnDeactivated;
if (_incidentModeService.CurrentState is { } state && !_incidentModeService.CurrentState.IsExpired)
{
ApplyIncidentState(state, wasReactivation: false);
}
}
}
public bool IsActive => _current.IsActive;
public LedgerIncidentSnapshot Current => _current;
public void RecordProjectionLag(ProjectionLagSample sample)
{
if (!_options.Enabled || !IsActive || !_options.CaptureLagTraces)
{
return;
}
EnqueueWithLimit(_lagSamples, sample, _options.LagTraceBufferSize);
if (_options.EmitTimelineEvents && sample.LagSeconds >= _options.LagTraceThresholdSeconds)
{
var now = sample.ObservedAt;
var key = $"{sample.TenantId}:{sample.ChainId}";
if (!_lastLagLogByChain.TryGetValue(key, out var lastLogged) ||
now - lastLogged >= TimeSpan.FromMinutes(1))
{
_lastLagLogByChain[key] = now;
LedgerTimeline.EmitIncidentLagTrace(_logger, sample);
}
}
}
public void RecordConflict(ConflictSnapshot snapshot)
{
if (!_options.Enabled || !IsActive || !_options.CaptureConflictSnapshots)
{
return;
}
EnqueueWithLimit(_conflictSnapshots, snapshot, _options.ConflictSnapshotBufferSize);
if (_options.EmitTimelineEvents)
{
LedgerTimeline.EmitIncidentConflictSnapshot(_logger, snapshot);
}
}
public void RecordReplayTrace(ReplayTraceSample sample)
{
if (!_options.Enabled || !IsActive || !_options.CaptureReplayTraces)
{
return;
}
EnqueueWithLimit(_replayTraces, sample, _options.ReplayTraceBufferSize);
if (_options.EmitTimelineEvents &&
(sample.DurationMs >= ReplayTraceLogThresholdMs || sample.HasMore))
{
LedgerTimeline.EmitIncidentReplayTrace(_logger, sample);
}
}
public IncidentDiagnosticsSnapshot GetDiagnosticsSnapshot()
{
return new IncidentDiagnosticsSnapshot(
_current,
_lagSamples.ToArray(),
_conflictSnapshots.ToArray(),
_replayTraces.ToArray(),
_timeProvider.GetUtcNow());
}
private void OnActivated(object? sender, IncidentModeActivatedEventArgs e)
{
ApplyIncidentState(e.State, e.WasReactivation);
}
private void OnDeactivated(object? sender, IncidentModeDeactivatedEventArgs e)
{
if (!_options.Enabled)
{
return;
}
lock (_stateLock)
{
_current = new LedgerIncidentSnapshot(
IsActive: false,
ActivationId: e.State.ActivationId,
Actor: e.DeactivatedBy,
Reason: e.Reason.ToString(),
TenantId: e.State.TenantId,
ChangedAt: _timeProvider.GetUtcNow(),
ExpiresAt: e.State.ExpiresAt,
RetentionExtensionDays: 0);
}
if (_options.EmitTimelineEvents)
{
LedgerTimeline.EmitIncidentModeChanged(_logger, _current, wasReactivation: false);
}
if (_options.EmitNotifications)
{
_ = SafeNotifyAsync(_current);
}
}
private void ApplyIncidentState(IncidentModeState state, bool wasReactivation)
{
if (!_options.Enabled)
{
return;
}
lock (_stateLock)
{
_current = new LedgerIncidentSnapshot(
IsActive: true,
ActivationId: state.ActivationId,
Actor: state.Actor,
Reason: state.Reason,
TenantId: state.TenantId,
ChangedAt: _timeProvider.GetUtcNow(),
ExpiresAt: state.ExpiresAt,
RetentionExtensionDays: _options.RetentionExtensionDays);
if (_options.ResetDiagnosticsOnActivation)
{
ClearDiagnostics();
}
}
if (_options.EmitTimelineEvents)
{
LedgerTimeline.EmitIncidentModeChanged(_logger, _current, wasReactivation);
}
if (_options.EmitNotifications)
{
_ = SafeNotifyAsync(_current);
}
}
private Task SafeNotifyAsync(LedgerIncidentSnapshot snapshot)
{
try
{
return _notifier.PublishIncidentModeChangedAsync(snapshot, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish incident mode notification.");
return Task.CompletedTask;
}
}
private void ClearDiagnostics()
{
while (_lagSamples.TryDequeue(out _))
{
}
while (_conflictSnapshots.TryDequeue(out _))
{
}
while (_replayTraces.TryDequeue(out _))
{
}
}
private static void EnqueueWithLimit<T>(ConcurrentQueue<T> queue, T item, int limit)
{
queue.Enqueue(item);
while (queue.Count > limit && queue.TryDequeue(out _))
{
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
if (_incidentModeService is not null)
{
_incidentModeService.Activated -= OnActivated;
_incidentModeService.Deactivated -= OnDeactivated;
}
_disposed = true;
}
}

View File

@@ -5,6 +5,7 @@ using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Hashing;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Services.Incident;
namespace StellaOps.Findings.Ledger.Services;
@@ -18,15 +19,18 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
private readonly ILedgerEventRepository _repository;
private readonly IMerkleAnchorScheduler _merkleAnchorScheduler;
private readonly ILogger<LedgerEventWriteService> _logger;
private readonly ILedgerIncidentDiagnostics? _incidentDiagnostics;
public LedgerEventWriteService(
ILedgerEventRepository repository,
IMerkleAnchorScheduler merkleAnchorScheduler,
ILogger<LedgerEventWriteService> logger)
ILogger<LedgerEventWriteService> logger,
ILedgerIncidentDiagnostics? incidentDiagnostics = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_merkleAnchorScheduler = merkleAnchorScheduler ?? throw new ArgumentNullException(nameof(merkleAnchorScheduler));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_incidentDiagnostics = incidentDiagnostics;
}
public async Task<LedgerWriteResult> AppendAsync(LedgerEventDraft draft, CancellationToken cancellationToken)
@@ -57,6 +61,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
if (!string.Equals(existing.CanonicalJson, canonicalJson, StringComparison.Ordinal))
{
LedgerTelemetry.MarkError(activity, "event_id_conflict");
RecordConflictSnapshot(draft, expectedSequence: existing.SequenceNumber + 1, reason: "event_id_conflict", expectedPreviousHash: existing.EventHash);
return LedgerWriteResult.Conflict(
"event_id_conflict",
$"Event '{draft.EventId}' already exists with a different payload.");
@@ -71,6 +76,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
if (draft.SequenceNumber != expectedSequence)
{
LedgerTelemetry.MarkError(activity, "sequence_mismatch");
RecordConflictSnapshot(draft, expectedSequence, reason: "sequence_mismatch", expectedPreviousHash: chainHead?.EventHash);
return LedgerWriteResult.Conflict(
"sequence_mismatch",
$"Sequence number '{draft.SequenceNumber}' does not match expected '{expectedSequence}'.");
@@ -80,6 +86,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
if (draft.ProvidedPreviousHash is not null && !string.Equals(draft.ProvidedPreviousHash, previousHash, StringComparison.OrdinalIgnoreCase))
{
LedgerTelemetry.MarkError(activity, "previous_hash_mismatch");
RecordConflictSnapshot(draft, expectedSequence, reason: "previous_hash_mismatch", providedPreviousHash: draft.ProvidedPreviousHash, expectedPreviousHash: previousHash);
return LedgerWriteResult.Conflict(
"previous_hash_mismatch",
$"Provided previous hash '{draft.ProvidedPreviousHash}' does not match chain head hash '{previousHash}'.");
@@ -143,11 +150,13 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
var persisted = await _repository.GetByEventIdAsync(draft.TenantId, draft.EventId, cancellationToken).ConfigureAwait(false);
if (persisted is null)
{
RecordConflictSnapshot(draft, expectedSequence, reason: "append_failed", expectedPreviousHash: previousHash);
return LedgerWriteResult.Conflict("append_failed", "Ledger append failed due to concurrent write.");
}
if (!string.Equals(persisted.CanonicalJson, record.CanonicalJson, StringComparison.Ordinal))
{
RecordConflictSnapshot(draft, expectedSequence, reason: "event_id_conflict", expectedPreviousHash: persisted.EventHash);
return LedgerWriteResult.Conflict("event_id_conflict", "Ledger append raced with conflicting payload.");
}
@@ -157,6 +166,37 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
return LedgerWriteResult.Success(record);
}
private void RecordConflictSnapshot(
LedgerEventDraft draft,
long expectedSequence,
string reason,
string? providedPreviousHash = null,
string? expectedPreviousHash = null)
{
if (_incidentDiagnostics is null)
{
return;
}
var snapshot = new ConflictSnapshot(
TenantId: draft.TenantId,
ChainId: draft.ChainId,
SequenceNumber: draft.SequenceNumber,
EventId: draft.EventId,
EventType: draft.EventType,
PolicyVersion: draft.PolicyVersion ?? string.Empty,
Reason: reason,
RecordedAt: draft.RecordedAt,
ObservedAt: DateTimeOffset.UtcNow,
ActorId: draft.ActorId,
ActorType: draft.ActorType,
ExpectedSequence: expectedSequence,
ProvidedPreviousHash: providedPreviousHash,
ExpectedPreviousHash: expectedPreviousHash);
_incidentDiagnostics.RecordConflict(snapshot);
}
private static string DetermineSource(LedgerEventDraft draft)
{
if (draft.SourceRunId.HasValue)

View File

@@ -154,7 +154,12 @@ public sealed class ScoredFindingsExportService : IScoredFindingsExportService
finding.RiskProfileVersion,
finding.RiskExplanationId,
finding.ExplainRef,
finding.UpdatedAt
finding.UpdatedAt,
finding.AttestationStatus,
finding.AttestationCount,
finding.VerifiedAttestationCount,
finding.FailedAttestationCount,
finding.UnverifiedAttestationCount
};
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
@@ -18,6 +20,9 @@ public sealed record ScoredFindingsQuery
public int Limit { get; init; } = 50;
public ScoredFindingsSortField SortBy { get; init; } = ScoredFindingsSortField.RiskScore;
public bool Descending { get; init; } = true;
public IReadOnlyList<AttestationType>? AttestationTypes { get; init; }
public AttestationVerificationFilter? AttestationVerification { get; init; }
public OverallVerificationStatus? AttestationStatus { get; init; }
}
/// <summary>
@@ -57,6 +62,11 @@ public sealed record ScoredFinding
public Guid? RiskExplanationId { get; init; }
public string? ExplainRef { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public int AttestationCount { get; init; }
public int VerifiedAttestationCount { get; init; }
public int FailedAttestationCount { get; init; }
public int UnverifiedAttestationCount { get; init; }
public OverallVerificationStatus AttestationStatus { get; init; } = OverallVerificationStatus.NoAttestations;
}
/// <summary>

View File

@@ -164,7 +164,12 @@ public sealed class ScoredFindingsQueryService : IScoredFindingsQueryService
RiskProfileVersion = projection.RiskProfileVersion,
RiskExplanationId = projection.RiskExplanationId,
ExplainRef = projection.ExplainRef,
UpdatedAt = projection.UpdatedAt
UpdatedAt = projection.UpdatedAt,
AttestationCount = projection.AttestationCount,
VerifiedAttestationCount = projection.VerifiedAttestationCount,
FailedAttestationCount = projection.FailedAttestationCount,
UnverifiedAttestationCount = projection.UnverifiedAttestationCount,
AttestationStatus = projection.AttestationStatus
};
}

View File

@@ -1,5 +1,6 @@
namespace StellaOps.Findings.Ledger.Services;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Services.Incident;
/// <summary>
/// Service for managing ledger snapshots and time-travel queries.
@@ -17,15 +19,18 @@ public sealed class SnapshotService
private readonly ITimeTravelRepository _timeTravelRepository;
private readonly ILogger<SnapshotService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILedgerIncidentDiagnostics? _incidentDiagnostics;
public SnapshotService(
ISnapshotRepository snapshotRepository,
ITimeTravelRepository timeTravelRepository,
ILogger<SnapshotService> logger)
ILogger<SnapshotService> logger,
ILedgerIncidentDiagnostics? incidentDiagnostics = null)
{
_snapshotRepository = snapshotRepository;
_timeTravelRepository = timeTravelRepository;
_logger = logger;
_incidentDiagnostics = incidentDiagnostics;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -42,32 +47,33 @@ public sealed class SnapshotService
{
try
{
var effectiveInput = ApplyIncidentRetention(input);
_logger.LogInformation(
"Creating snapshot for tenant {TenantId} at sequence {Sequence} / timestamp {Timestamp}",
input.TenantId,
input.AtSequence,
input.AtTimestamp);
effectiveInput.TenantId,
effectiveInput.AtSequence,
effectiveInput.AtTimestamp);
// Get current ledger state
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(input.TenantId, ct);
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(effectiveInput.TenantId, ct);
// Create the snapshot record
var snapshot = await _snapshotRepository.CreateAsync(
input.TenantId,
input,
effectiveInput.TenantId,
effectiveInput,
currentPoint.SequenceNumber,
currentPoint.Timestamp,
ct);
// Compute statistics asynchronously
var statistics = await ComputeStatisticsAsync(
input.TenantId,
effectiveInput.TenantId,
snapshot.SequenceNumber,
input.IncludeEntityTypes,
effectiveInput.IncludeEntityTypes,
ct);
await _snapshotRepository.UpdateStatisticsAsync(
input.TenantId,
effectiveInput.TenantId,
snapshot.SnapshotId,
statistics,
ct);
@@ -79,12 +85,12 @@ public sealed class SnapshotService
if (input.Sign)
{
merkleRoot = await ComputeMerkleRootAsync(
input.TenantId,
effectiveInput.TenantId,
snapshot.SequenceNumber,
ct);
await _snapshotRepository.SetMerkleRootAsync(
input.TenantId,
effectiveInput.TenantId,
snapshot.SnapshotId,
merkleRoot,
dsseDigest,
@@ -93,20 +99,20 @@ public sealed class SnapshotService
// Mark as available
await _snapshotRepository.UpdateStatusAsync(
input.TenantId,
effectiveInput.TenantId,
snapshot.SnapshotId,
SnapshotStatus.Available,
ct);
// Retrieve updated snapshot
var finalSnapshot = await _snapshotRepository.GetByIdAsync(
input.TenantId,
effectiveInput.TenantId,
snapshot.SnapshotId,
ct);
LedgerTimeline.EmitSnapshotCreated(
_logger,
input.TenantId,
effectiveInput.TenantId,
snapshot.SnapshotId,
snapshot.SequenceNumber,
statistics.FindingsCount);
@@ -196,7 +202,20 @@ public sealed class SnapshotService
ReplayRequest request,
CancellationToken ct = default)
{
return await _timeTravelRepository.ReplayEventsAsync(request, ct);
var result = await _timeTravelRepository.ReplayEventsAsync(request, ct);
_incidentDiagnostics?.RecordReplayTrace(new ReplayTraceSample(
TenantId: request.TenantId,
FromSequence: result.Metadata.FromSequence,
ToSequence: result.Metadata.ToSequence,
EventsCount: result.Metadata.EventsCount,
HasMore: result.Metadata.HasMore,
DurationMs: result.Metadata.ReplayDurationMs,
ObservedAt: DateTimeOffset.UtcNow,
ChainFilterCount: request.ChainIds?.Count ?? 0,
EventTypeFilterCount: request.EventTypes?.Count ?? 0));
return result;
}
/// <summary>
@@ -249,6 +268,15 @@ public sealed class SnapshotService
public async Task<int> ExpireOldSnapshotsAsync(CancellationToken ct = default)
{
var cutoff = DateTimeOffset.UtcNow;
if (_incidentDiagnostics?.IsActive == true && _incidentDiagnostics.Current.RetentionExtensionDays > 0)
{
cutoff = cutoff.AddDays(-_incidentDiagnostics.Current.RetentionExtensionDays);
_logger.LogInformation(
"Incident mode active; extending snapshot expiry cutoff by {ExtensionDays} days (activation {ActivationId}).",
_incidentDiagnostics.Current.RetentionExtensionDays,
_incidentDiagnostics.Current.ActivationId ?? string.Empty);
}
var count = await _snapshotRepository.ExpireSnapshotsAsync(cutoff, ct);
if (count > 0)
@@ -367,4 +395,44 @@ public sealed class SnapshotService
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
private CreateSnapshotInput ApplyIncidentRetention(CreateSnapshotInput input)
{
if (_incidentDiagnostics is null || !_incidentDiagnostics.IsActive)
{
return input;
}
var incident = _incidentDiagnostics.Current;
if (incident.RetentionExtensionDays <= 0)
{
return input;
}
TimeSpan? expiresIn = input.ExpiresIn;
if (expiresIn.HasValue)
{
expiresIn = expiresIn.Value.Add(TimeSpan.FromDays(incident.RetentionExtensionDays));
}
var metadata = input.Metadata is null
? new Dictionary<string, object>()
: new Dictionary<string, object>(input.Metadata);
metadata["incident.mode"] = "enabled";
metadata["incident.activationId"] = incident.ActivationId ?? string.Empty;
metadata["incident.retentionExtensionDays"] = incident.RetentionExtensionDays;
metadata["incident.changedAt"] = incident.ChangedAt.ToString("O");
if (incident.ExpiresAt is not null)
{
metadata["incident.expiresAt"] = incident.ExpiresAt.Value.ToString("O");
}
_logger.LogInformation(
"Incident mode active; extending snapshot retention by {ExtensionDays} days (activation {ActivationId}).",
incident.RetentionExtensionDays,
incident.ActivationId ?? string.Empty);
return input with { ExpiresIn = expiresIn, Metadata = metadata };
}
}

View File

@@ -32,6 +32,7 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
# Findings Ledger · Sprint 0120-0000-0001
# Findings Ledger · Sprint 0120-0000-0001
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
@@ -8,9 +8,18 @@
Status changes must be mirrored in `docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md`.
# Findings Ledger · Sprint 0121-0001-0001
# Findings Ledger · Sprint 0121-0001-0001
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| LEDGER-OBS-54-001 | DONE | Implemented `/v1/ledger/attestations` with deterministic paging, filter hash guard, and schema/OpenAPI updates. | 2025-11-22 |
| LEDGER-GAPS-121-009 | DONE | FL1FL10 remediation: schema catalog + export canonicals, Merkle/external anchor policy, tenant isolation/redaction manifest, offline verifier + checksum guard, golden fixtures, backpressure metrics. | 2025-12-02 |
| LEDGER-GAPS-121-009 | DONE | FL1–FL10 remediation: schema catalog + export canonicals, Merkle/external anchor policy, tenant isolation/redaction manifest, offline verifier + checksum guard, golden fixtures, backpressure metrics. | 2025-12-02 |
# Findings Ledger Aú Sprint 0121-0001-0002
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| LEDGER-ATTEST-73-002 | DONE | Verification-result and attestation-status filters wired into findings projection queries and exports; tests added. | 2025-12-08 |
| LEDGER-OAS-61-002 | DONE | `/.well-known/openapi` serves spec with version/build headers, ETag, cache hints. | 2025-12-08 |
| LEDGER-OAS-62-001 | DONE | SDK-facing OpenAPI assertions for pagination, evidence links, provenance added. | 2025-12-08 |
| LEDGER-OAS-63-001 | DONE | Deprecation headers and notifications applied to legacy findings export endpoint. | 2025-12-08 |
| LEDGER-OBS-55-001 | DONE | Incident-mode diagnostics (lag/conflict/replay traces), retention extension for snapshots, timeline/notifier hooks. | 2025-12-08 |

View File

@@ -0,0 +1,18 @@
using FluentAssertions;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
namespace StellaOps.Findings.Ledger.Tests;
public class AttestationStatusCalculatorTests
{
[Theory]
[InlineData(0, 0, OverallVerificationStatus.NoAttestations)]
[InlineData(3, 3, OverallVerificationStatus.AllVerified)]
[InlineData(4, 1, OverallVerificationStatus.PartiallyVerified)]
[InlineData(2, 0, OverallVerificationStatus.NoneVerified)]
public void Compute_ReturnsExpectedStatus(int attestationCount, int verifiedCount, OverallVerificationStatus expected)
{
AttestationStatusCalculator.Compute(attestationCount, verifiedCount)
.Should().Be(expected);
}
}

View File

@@ -0,0 +1,20 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using StellaOps.Findings.Ledger;
namespace StellaOps.Findings.Ledger.Tests;
public class DeprecationHeadersTests
{
[Fact]
public void Apply_SetsStandardDeprecationHeaders()
{
var context = new DefaultHttpContext();
DeprecationHeaders.Apply(context.Response, "ledger.export.findings");
context.Response.Headers["Deprecation"].ToString().Should().Be("true");
context.Response.Headers["Sunset"].ToString().Should().Be(DeprecationHeaders.SunsetDate);
context.Response.Headers["X-Deprecated-Endpoint"].ToString().Should().Be("ledger.export.findings");
context.Response.Headers["Link"].ToString().Should().Contain("/.well-known/openapi");
}
}

View File

@@ -0,0 +1,28 @@
using System.Text;
using FluentAssertions;
using StellaOps.Findings.Ledger.OpenApi;
namespace StellaOps.Findings.Ledger.Tests;
public class OpenApiMetadataFactoryTests
{
[Fact]
public void ComputeEtag_IsDeterministicAndWeak()
{
var bytes = Encoding.UTF8.GetBytes("spec-content");
var etag1 = OpenApiMetadataFactory.ComputeEtag(bytes);
var etag2 = OpenApiMetadataFactory.ComputeEtag(bytes);
etag1.Should().StartWith("W/\"");
etag1.Should().Be(etag2);
etag1.Length.Should().BeGreaterThan(6);
}
[Fact]
public void GetSpecPath_ResolvesExistingSpec()
{
var path = OpenApiMetadataFactory.GetSpecPath(AppContext.BaseDirectory);
File.Exists(path).Should().BeTrue();
}
}

View File

@@ -0,0 +1,39 @@
using System.Text;
using FluentAssertions;
using StellaOps.Findings.Ledger.OpenApi;
namespace StellaOps.Findings.Ledger.Tests;
public class OpenApiSdkSurfaceTests
{
private readonly string _specContent;
public OpenApiSdkSurfaceTests()
{
var path = OpenApiMetadataFactory.GetSpecPath(AppContext.BaseDirectory);
_specContent = File.ReadAllText(path, Encoding.UTF8);
}
[Fact]
public void FindingsEndpoints_ExposePaginationAndFilters()
{
_specContent.Should().Contain("/findings");
_specContent.Should().Contain("page_token");
_specContent.Should().MatchRegex("nextPageToken|next_page_token");
}
[Fact]
public void EvidenceSchemas_ExposeEvidenceLinks()
{
_specContent.Should().Contain("evidenceBundleRef");
_specContent.Should().Contain("ExportProvenance");
}
[Fact]
public void AttestationPointers_ExposeProvenanceMetadata()
{
_specContent.Should().Contain("/v1/ledger/attestations");
_specContent.Should().Contain("attestation");
_specContent.Should().Contain("provenance");
}
}

View File

@@ -0,0 +1,116 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
using StellaOps.Findings.Ledger.Services;
using FluentAssertions;
namespace StellaOps.Findings.Ledger.Tests;
public class ScoredFindingsQueryServiceTests
{
[Fact]
public async Task QueryAsync_MapsAttestationMetadata()
{
var projection = new FindingProjection(
TenantId: "tenant-a",
FindingId: "finding-123",
PolicyVersion: "v1",
Status: "affected",
Severity: 7.5m,
RiskScore: 0.9m,
RiskSeverity: "critical",
RiskProfileVersion: "p1",
RiskExplanationId: Guid.NewGuid(),
RiskEventSequence: 42,
Labels: new(),
CurrentEventId: Guid.NewGuid(),
ExplainRef: "explain-1",
PolicyRationale: new(),
UpdatedAt: DateTimeOffset.UtcNow,
CycleHash: "abc123",
AttestationCount: 3,
VerifiedAttestationCount: 2,
FailedAttestationCount: 1,
UnverifiedAttestationCount: 0,
AttestationStatus: OverallVerificationStatus.PartiallyVerified);
var repo = new FakeFindingProjectionRepository(projection);
var service = new ScoredFindingsQueryService(
repo,
new NullRiskExplanationStore(),
TimeProvider.System,
NullLogger<ScoredFindingsQueryService>.Instance);
var result = await service.QueryAsync(new ScoredFindingsQuery
{
TenantId = "tenant-a",
Limit = 10
});
result.TotalCount.Should().Be(1);
result.Findings.Should().HaveCount(1);
var finding = result.Findings.Single();
finding.AttestationCount.Should().Be(3);
finding.VerifiedAttestationCount.Should().Be(2);
finding.FailedAttestationCount.Should().Be(1);
finding.UnverifiedAttestationCount.Should().Be(0);
finding.AttestationStatus.Should().Be(OverallVerificationStatus.PartiallyVerified);
}
private sealed class FakeFindingProjectionRepository : IFindingProjectionRepository
{
private readonly FindingProjection _projection;
public FakeFindingProjectionRepository(FindingProjection projection)
{
_projection = projection;
}
public Task<ProjectionCheckpoint> GetCheckpointAsync(CancellationToken cancellationToken) =>
Task.FromResult(ProjectionCheckpoint.Initial(TimeProvider.System));
public Task<(IReadOnlyList<FindingProjection> Projections, int TotalCount)> QueryScoredAsync(
ScoredFindingsQuery query,
CancellationToken cancellationToken) =>
Task.FromResult((new List<FindingProjection> { _projection } as IReadOnlyList<FindingProjection>, 1));
public Task<FindingStatsResult> GetFindingStatsSinceAsync(string tenantId, DateTimeOffset since, CancellationToken cancellationToken) =>
Task.FromResult(new FindingStatsResult(0, 0, 0, 0, 0, 0));
public Task<(int Total, int Scored, decimal AvgScore, decimal MaxScore)> GetRiskAggregatesAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
Task.FromResult((0, 0, 0m, 0m));
public Task<ScoreDistribution> GetScoreDistributionAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
Task.FromResult(new ScoreDistribution());
public Task<SeverityDistribution> GetSeverityDistributionAsync(string tenantId, string? policyVersion, CancellationToken cancellationToken) =>
Task.FromResult(new SeverityDistribution());
public Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task InsertHistoryAsync(FindingHistoryEntry entry, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task InsertActionAsync(TriageActionEntry entry, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken) =>
Task.FromResult<FindingProjection?>(_projection);
public Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken) =>
Task.CompletedTask;
}
private sealed class NullRiskExplanationStore : IRiskExplanationStore
{
public Task<ScoredFindingExplanation?> GetAsync(string tenantId, string findingId, Guid? explanationId, CancellationToken cancellationToken) =>
Task.FromResult<ScoredFindingExplanation?>(null);
public Task StoreAsync(string tenantId, ScoredFindingExplanation explanation, CancellationToken cancellationToken) =>
Task.CompletedTask;
}
}