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