feat: Implement air-gap functionality with timeline impact and evidence snapshot services
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

- Added AirgapTimelineImpact, AirgapTimelineImpactInput, and AirgapTimelineImpactResult records for managing air-gap bundle import impacts.
- Introduced EvidenceSnapshotRecord, EvidenceSnapshotLinkInput, and EvidenceSnapshotLinkResult records for linking findings to evidence snapshots.
- Created IEvidenceSnapshotRepository interface for managing evidence snapshot records.
- Developed StalenessValidationService to validate staleness and enforce freshness thresholds.
- Implemented AirgapTimelineService for emitting timeline events related to bundle imports.
- Added EvidenceSnapshotService for linking findings to evidence snapshots and verifying their validity.
- Introduced AirGapOptions for configuring air-gap staleness enforcement and thresholds.
- Added minimal jsPDF stub for offline/testing builds in the web application.
- Created TypeScript definitions for jsPDF to enhance type safety in the web application.
This commit is contained in:
StellaOps Bot
2025-12-06 01:30:08 +02:00
parent 6c1177a6ce
commit 2eaf0f699b
144 changed files with 7578 additions and 2581 deletions

View File

@@ -15,6 +15,8 @@ public static class LedgerEventConstants
public const string EventFindingAttachmentAdded = "finding.attachment_added";
public const string EventFindingClosed = "finding.closed";
public const string EventAirgapBundleImported = "airgap.bundle_imported";
public const string EventEvidenceSnapshotLinked = "airgap.evidence_snapshot_linked";
public const string EventAirgapTimelineImpact = "airgap.timeline_impact";
public const string EventOrchestratorExportRecorded = "orchestrator.export_recorded";
public static readonly ImmutableHashSet<string> SupportedEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
@@ -29,6 +31,8 @@ public static class LedgerEventConstants
EventFindingAttachmentAdded,
EventFindingClosed,
EventAirgapBundleImported,
EventEvidenceSnapshotLinked,
EventAirgapTimelineImpact,
EventOrchestratorExportRecorded);
public static readonly ImmutableHashSet<string> FindingEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
/// <summary>
/// Represents the impact of an air-gap bundle import on findings.
/// </summary>
public sealed record AirgapTimelineImpact(
string TenantId,
string BundleId,
int NewFindings,
int ResolvedFindings,
int CriticalDelta,
int HighDelta,
int MediumDelta,
int LowDelta,
DateTimeOffset TimeAnchor,
bool SealedMode,
DateTimeOffset CalculatedAt,
Guid? LedgerEventId);
/// <summary>
/// Input for calculating and emitting bundle import timeline impact.
/// </summary>
public sealed record AirgapTimelineImpactInput(
string TenantId,
string BundleId,
DateTimeOffset TimeAnchor,
bool SealedMode);
/// <summary>
/// Result of emitting a timeline impact event.
/// </summary>
public sealed record AirgapTimelineImpactResult(
bool Success,
AirgapTimelineImpact? Impact,
Guid? EventId,
string? Error);

View File

@@ -0,0 +1,31 @@
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
/// <summary>
/// Record linking a finding to an evidence snapshot in a portable bundle.
/// </summary>
public sealed record EvidenceSnapshotRecord(
string TenantId,
string FindingId,
string BundleUri,
string DsseDigest,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
Guid? LedgerEventId);
/// <summary>
/// Input for creating an evidence snapshot link.
/// </summary>
public sealed record EvidenceSnapshotLinkInput(
string TenantId,
string FindingId,
string BundleUri,
string DsseDigest,
TimeSpan? ValidFor);
/// <summary>
/// Result of linking an evidence snapshot.
/// </summary>
public sealed record EvidenceSnapshotLinkResult(
bool Success,
Guid? EventId,
string? Error);

View File

@@ -3,4 +3,27 @@ namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
public interface IAirgapImportRepository
{
Task InsertAsync(AirgapImportRecord record, CancellationToken cancellationToken);
/// <summary>
/// Gets the latest import record for a specific domain.
/// </summary>
Task<AirgapImportRecord?> GetLatestByDomainAsync(
string tenantId,
string domainId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the latest import records for all domains in a tenant.
/// </summary>
Task<IReadOnlyList<AirgapImportRecord>> GetAllLatestByDomainAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the count of bundles imported for a specific domain.
/// </summary>
Task<int> GetBundleCountByDomainAsync(
string tenantId,
string domainId,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
/// <summary>
/// Repository for managing evidence snapshot links.
/// </summary>
public interface IEvidenceSnapshotRepository
{
/// <summary>
/// Inserts a new evidence snapshot record.
/// </summary>
Task InsertAsync(EvidenceSnapshotRecord record, CancellationToken cancellationToken);
/// <summary>
/// Gets evidence snapshots for a finding.
/// </summary>
Task<IReadOnlyList<EvidenceSnapshotRecord>> GetByFindingIdAsync(
string tenantId,
string findingId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the latest evidence snapshot for a finding.
/// </summary>
Task<EvidenceSnapshotRecord?> GetLatestByFindingIdAsync(
string tenantId,
string findingId,
CancellationToken cancellationToken);
/// <summary>
/// Gets all evidence snapshots for a bundle.
/// </summary>
Task<IReadOnlyList<EvidenceSnapshotRecord>> GetByBundleUriAsync(
string tenantId,
string bundleUri,
CancellationToken cancellationToken);
/// <summary>
/// Checks if an evidence snapshot exists and is not expired.
/// </summary>
Task<bool> ExistsValidAsync(
string tenantId,
string findingId,
string dsseDigest,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,92 @@
using StellaOps.Findings.Ledger.Options;
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
/// <summary>
/// Result of a staleness validation check.
/// </summary>
public sealed record StalenessValidationResult(
bool Passed,
string? DomainId,
long StalenessSeconds,
int ThresholdSeconds,
StalenessEnforcementMode EnforcementMode,
StalenessError? Error,
IReadOnlyList<StalenessWarning> Warnings);
/// <summary>
/// Error returned when staleness validation fails.
/// </summary>
public sealed record StalenessError(
StalenessErrorCode Code,
string Message,
string? DomainId,
long StalenessSeconds,
int ThresholdSeconds,
string Recommendation);
/// <summary>
/// Warning generated during staleness validation.
/// </summary>
public sealed record StalenessWarning(
StalenessWarningCode Code,
string Message,
double PercentOfThreshold,
DateTimeOffset? ProjectedStaleAt);
/// <summary>
/// Staleness error codes.
/// </summary>
public enum StalenessErrorCode
{
ErrAirgapStale,
ErrAirgapNoBundle,
ErrAirgapTimeAnchorMissing,
ErrAirgapTimeDrift,
ErrAirgapAttestationInvalid
}
/// <summary>
/// Staleness warning codes.
/// </summary>
public enum StalenessWarningCode
{
WarnAirgapApproachingStale,
WarnAirgapTimeUncertaintyHigh,
WarnAirgapBundleOld,
WarnAirgapNoRecentImport
}
/// <summary>
/// Staleness metrics for a domain.
/// </summary>
public sealed record DomainStalenessMetric(
string DomainId,
long StalenessSeconds,
DateTimeOffset LastImportAt,
DateTimeOffset? LastSourceTimestamp,
int BundleCount,
bool IsStale,
double PercentOfThreshold,
DateTimeOffset? ProjectedStaleAt);
/// <summary>
/// Aggregate staleness metrics.
/// </summary>
public sealed record AggregateStalenessMetrics(
int TotalDomains,
int StaleDomains,
int WarningDomains,
int HealthyDomains,
long MaxStalenessSeconds,
double AvgStalenessSeconds,
DateTimeOffset? OldestBundle);
/// <summary>
/// Complete staleness metrics snapshot.
/// </summary>
public sealed record StalenessMetricsSnapshot(
DateTimeOffset CollectedAt,
string? TenantId,
IReadOnlyList<DomainStalenessMetric> DomainMetrics,
AggregateStalenessMetrics Aggregates);

View File

@@ -2,6 +2,17 @@ using StellaOps.Findings.Ledger.Domain;
namespace StellaOps.Findings.Ledger.Infrastructure;
/// <summary>
/// Statistics about finding changes since a given timestamp.
/// </summary>
public sealed record FindingStatsResult(
int NewFindings,
int ResolvedFindings,
int CriticalDelta,
int HighDelta,
int MediumDelta,
int LowDelta);
public interface IFindingProjectionRepository
{
Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken);
@@ -15,4 +26,12 @@ public interface IFindingProjectionRepository
Task<ProjectionCheckpoint> GetCheckpointAsync(CancellationToken cancellationToken);
Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken);
/// <summary>
/// Gets finding statistics since a given timestamp for timeline impact calculation.
/// </summary>
Task<FindingStatsResult> GetFindingStatsSinceAsync(
string tenantId,
DateTimeOffset since,
CancellationToken cancellationToken);
}

View File

@@ -45,6 +45,51 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
ledger_event_id = EXCLUDED.ledger_event_id;
""";
private const string SelectLatestByDomainSql = """
SELECT
tenant_id,
bundle_id,
mirror_generation,
merkle_root,
time_anchor,
publisher,
hash_algorithm,
contents,
imported_at,
import_operator,
ledger_event_id
FROM airgap_imports
WHERE tenant_id = @tenant_id
AND bundle_id = @domain_id
ORDER BY time_anchor DESC
LIMIT 1;
""";
private const string SelectAllLatestByDomainSql = """
SELECT DISTINCT ON (bundle_id)
tenant_id,
bundle_id,
mirror_generation,
merkle_root,
time_anchor,
publisher,
hash_algorithm,
contents,
imported_at,
import_operator,
ledger_event_id
FROM airgap_imports
WHERE tenant_id = @tenant_id
ORDER BY bundle_id, time_anchor DESC;
""";
private const string SelectBundleCountSql = """
SELECT COUNT(*)
FROM airgap_imports
WHERE tenant_id = @tenant_id
AND bundle_id = @domain_id;
""";
private readonly LedgerDataSource _dataSource;
private readonly ILogger<PostgresAirgapImportRepository> _logger;
@@ -91,4 +136,95 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
throw;
}
}
public async Task<AirgapImportRecord?> GetLatestByDomainAsync(
string tenantId,
string domainId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectLatestByDomainSql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
command.Parameters.Add(new NpgsqlParameter<string>("domain_id", domainId) { NpgsqlDbType = NpgsqlDbType.Text });
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapRecord(reader);
}
return null;
}
public async Task<IReadOnlyList<AirgapImportRecord>> GetAllLatestByDomainAsync(
string tenantId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var results = new List<AirgapImportRecord>();
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectAllLatestByDomainSql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRecord(reader));
}
return results;
}
public async Task<int> GetBundleCountByDomainAsync(
string tenantId,
string domainId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectBundleCountSql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
command.Parameters.Add(new NpgsqlParameter<string>("domain_id", domainId) { NpgsqlDbType = NpgsqlDbType.Text });
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static AirgapImportRecord MapRecord(NpgsqlDataReader reader)
{
var contentsJson = reader.GetString(7);
var contents = JsonNode.Parse(contentsJson) as JsonArray ?? new JsonArray();
return new AirgapImportRecord(
TenantId: reader.GetString(0),
BundleId: reader.GetString(1),
MirrorGeneration: reader.IsDBNull(2) ? null : reader.GetString(2),
MerkleRoot: reader.GetString(3),
TimeAnchor: reader.GetDateTime(4),
Publisher: reader.IsDBNull(5) ? null : reader.GetString(5),
HashAlgorithm: reader.IsDBNull(6) ? null : reader.GetString(6),
Contents: contents,
ImportedAt: reader.GetDateTime(8),
ImportOperator: reader.IsDBNull(9) ? null : reader.GetString(9),
LedgerEventId: reader.IsDBNull(10) ? null : reader.GetGuid(10));
}
}

View File

@@ -155,6 +155,22 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
updated_at = EXCLUDED.updated_at;
""";
private const string SelectFindingStatsSql = """
SELECT
COALESCE(SUM(CASE WHEN status = 'new' AND updated_at >= @since THEN 1 ELSE 0 END), 0) as new_findings,
COALESCE(SUM(CASE WHEN status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as resolved_findings,
COALESCE(SUM(CASE WHEN risk_severity = 'critical' AND updated_at >= @since THEN 1 ELSE 0 END) -
SUM(CASE WHEN risk_severity = 'critical' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as critical_delta,
COALESCE(SUM(CASE WHEN risk_severity = 'high' AND updated_at >= @since THEN 1 ELSE 0 END) -
SUM(CASE WHEN risk_severity = 'high' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as high_delta,
COALESCE(SUM(CASE WHEN risk_severity = 'medium' AND updated_at >= @since THEN 1 ELSE 0 END) -
SUM(CASE WHEN risk_severity = 'medium' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as medium_delta,
COALESCE(SUM(CASE WHEN risk_severity = 'low' AND updated_at >= @since THEN 1 ELSE 0 END) -
SUM(CASE WHEN risk_severity = 'low' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as low_delta
FROM findings_projection
WHERE tenant_id = @tenant_id
""";
private const string DefaultWorkerId = "default";
private readonly LedgerDataSource _dataSource;
@@ -350,4 +366,33 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
throw;
}
}
public async Task<FindingStatsResult> GetFindingStatsSinceAsync(
string tenantId,
DateTimeOffset since,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectFindingStatsSql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("since", since);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return new FindingStatsResult(
NewFindings: reader.GetInt32(0),
ResolvedFindings: reader.GetInt32(1),
CriticalDelta: reader.GetInt32(2),
HighDelta: reader.GetInt32(3),
MediumDelta: reader.GetInt32(4),
LowDelta: reader.GetInt32(5));
}
return new FindingStatsResult(0, 0, 0, 0, 0, 0);
}
}

View File

@@ -59,6 +59,21 @@ internal static class LedgerMetrics
"ledger_attachments_encryption_failures_total",
description: "Count of attachment encryption/signing/upload failures.");
private static readonly Histogram<double> AirgapStalenessSeconds = Meter.CreateHistogram<double>(
"ledger_airgap_staleness_seconds",
unit: "s",
description: "Current staleness of air-gap imported data by domain.");
private static readonly Counter<long> StalenessValidationFailures = Meter.CreateCounter<long>(
"ledger_staleness_validation_failures_total",
description: "Count of staleness validation failures blocking exports.");
private static readonly ObservableGauge<double> AirgapStalenessGauge =
Meter.CreateObservableGauge("ledger_airgap_staleness_gauge_seconds", ObserveAirgapStaleness, unit: "s",
description: "Current staleness of air-gap data by domain.");
private static readonly ConcurrentDictionary<string, double> AirgapStalenessByDomain = new(StringComparer.Ordinal);
private static readonly ObservableGauge<double> ProjectionLagGauge =
Meter.CreateObservableGauge("ledger_projection_lag_seconds", ObserveProjectionLag, unit: "s",
description: "Lag between ledger recorded_at and projection application time.");
@@ -228,6 +243,27 @@ internal static class LedgerMetrics
public static void RecordProjectionLag(TimeSpan lag, string? tenantId) =>
UpdateProjectionLag(tenantId, lag.TotalSeconds);
public static void RecordAirgapStaleness(string? domainId, long stalenessSeconds)
{
var key = string.IsNullOrWhiteSpace(domainId) ? "unknown" : domainId;
var tags = new KeyValuePair<string, object?>[]
{
new("domain", key)
};
AirgapStalenessSeconds.Record(stalenessSeconds, tags);
AirgapStalenessByDomain[key] = stalenessSeconds;
}
public static void RecordStalenessValidationFailure(string? domainId)
{
var key = string.IsNullOrWhiteSpace(domainId) ? "unknown" : domainId;
var tags = new KeyValuePair<string, object?>[]
{
new("domain", key)
};
StalenessValidationFailures.Add(1, tags);
}
private static IEnumerable<Measurement<double>> ObserveProjectionLag()
{
foreach (var kvp in ProjectionLagByTenant)
@@ -267,6 +303,14 @@ internal static class LedgerMetrics
new KeyValuePair<string, object?>("git_sha", GitSha));
}
private static IEnumerable<Measurement<double>> ObserveAirgapStaleness()
{
foreach (var kvp in AirgapStalenessByDomain)
{
yield return new Measurement<double>(kvp.Value, new KeyValuePair<string, object?>("domain", kvp.Key));
}
}
private static string NormalizeRole(string role) => string.IsNullOrWhiteSpace(role) ? "unspecified" : role.ToLowerInvariant();
private static string NormalizeTenant(string? tenantId) => string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId;

View File

@@ -15,6 +15,8 @@ internal static class LedgerTimeline
private static readonly EventId ProjectionUpdated = new(6201, "ledger.projection.updated");
private static readonly EventId OrchestratorExport = new(6301, "ledger.export.recorded");
private static readonly EventId AirgapImport = new(6401, "ledger.airgap.imported");
private static readonly EventId EvidenceSnapshotLinkedEvent = new(6501, "ledger.evidence.snapshot_linked");
private static readonly EventId AirgapTimelineImpactEvent = new(6601, "ledger.airgap.timeline_impact");
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
{
@@ -99,4 +101,47 @@ internal static class LedgerTimeline
merkleRoot,
ledgerEventId?.ToString() ?? string.Empty);
}
public static void EmitEvidenceSnapshotLinked(ILogger logger, string tenantId, string findingId, string bundleUri, string dsseDigest)
{
if (logger is null)
{
return;
}
logger.LogInformation(
EvidenceSnapshotLinkedEvent,
"timeline ledger.evidence.snapshot_linked tenant={Tenant} finding={FindingId} bundle_uri={BundleUri} dsse_digest={DsseDigest}",
tenantId,
findingId,
bundleUri,
dsseDigest);
}
public static void EmitAirgapTimelineImpact(
ILogger logger,
string tenantId,
string bundleId,
int newFindings,
int resolvedFindings,
int criticalDelta,
DateTimeOffset timeAnchor,
bool sealedMode)
{
if (logger is null)
{
return;
}
logger.LogInformation(
AirgapTimelineImpactEvent,
"timeline ledger.airgap.timeline_impact tenant={Tenant} bundle={BundleId} new_findings={NewFindings} resolved_findings={ResolvedFindings} critical_delta={CriticalDelta} time_anchor={TimeAnchor} sealed_mode={SealedMode}",
tenantId,
bundleId,
newFindings,
resolvedFindings,
criticalDelta,
timeAnchor.ToString("O"),
sealedMode);
}
}

View File

@@ -0,0 +1,98 @@
namespace StellaOps.Findings.Ledger.Options;
/// <summary>
/// Configuration for air-gap staleness enforcement and freshness thresholds.
/// </summary>
public sealed class AirGapOptions
{
public const string SectionName = "findings:ledger:airgap";
/// <summary>
/// Maximum age in seconds before data is considered stale.
/// Default: 604800 seconds (7 days).
/// </summary>
public int FreshnessThresholdSeconds { get; set; } = 604800;
/// <summary>
/// Grace period in seconds after threshold before hard enforcement.
/// Default: 86400 seconds (1 day).
/// </summary>
public int GracePeriodSeconds { get; set; } = 86400;
/// <summary>
/// How staleness violations are handled.
/// </summary>
public StalenessEnforcementMode EnforcementMode { get; set; } = StalenessEnforcementMode.Strict;
/// <summary>
/// Domains exempt from staleness enforcement.
/// </summary>
public IList<string> AllowedDomains { get; } = new List<string>();
/// <summary>
/// Percentage thresholds for warning notifications.
/// </summary>
public IList<NotificationThresholdConfig> NotificationThresholds { get; } = new List<NotificationThresholdConfig>
{
new() { PercentOfThreshold = 75, Severity = NotificationSeverity.Warning },
new() { PercentOfThreshold = 90, Severity = NotificationSeverity.Critical }
};
/// <summary>
/// Whether to emit staleness metrics.
/// </summary>
public bool EmitMetrics { get; set; } = true;
public void Validate()
{
if (FreshnessThresholdSeconds <= 0)
{
throw new InvalidOperationException("FreshnessThresholdSeconds must be greater than zero.");
}
if (GracePeriodSeconds < 0)
{
throw new InvalidOperationException("GracePeriodSeconds must be non-negative.");
}
}
}
/// <summary>
/// Staleness enforcement mode.
/// </summary>
public enum StalenessEnforcementMode
{
/// <summary>
/// Block exports when stale.
/// </summary>
Strict,
/// <summary>
/// Warn but allow exports when stale.
/// </summary>
Warn,
/// <summary>
/// No enforcement.
/// </summary>
Disabled
}
/// <summary>
/// Notification threshold configuration.
/// </summary>
public sealed class NotificationThresholdConfig
{
public int PercentOfThreshold { get; set; }
public NotificationSeverity Severity { get; set; }
}
/// <summary>
/// Notification severity levels.
/// </summary>
public enum NotificationSeverity
{
Info,
Warning,
Critical
}

View File

@@ -0,0 +1,178 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
using StellaOps.Findings.Ledger.Observability;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for emitting timeline events for bundle import impacts.
/// </summary>
public sealed class AirgapTimelineService
{
private readonly ILedgerEventRepository _ledgerEventRepository;
private readonly ILedgerEventWriteService _writeService;
private readonly IFindingProjectionRepository _projectionRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AirgapTimelineService> _logger;
public AirgapTimelineService(
ILedgerEventRepository ledgerEventRepository,
ILedgerEventWriteService writeService,
IFindingProjectionRepository projectionRepository,
TimeProvider timeProvider,
ILogger<AirgapTimelineService> logger)
{
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
_projectionRepository = projectionRepository ?? throw new ArgumentNullException(nameof(projectionRepository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Calculates and emits a timeline event for bundle import impact.
/// </summary>
public async Task<AirgapTimelineImpactResult> EmitImpactAsync(
AirgapTimelineImpactInput input,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(input.BundleId);
var now = _timeProvider.GetUtcNow();
// Calculate impact by comparing findings before and after bundle time anchor
var impact = await CalculateImpactAsync(input, now, cancellationToken).ConfigureAwait(false);
// Create ledger event for the timeline impact
var chainId = LedgerChainIdGenerator.FromTenantSubject(input.TenantId, $"timeline::{input.BundleId}");
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(input.TenantId, chainId, cancellationToken)
.ConfigureAwait(false);
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
var eventId = Guid.NewGuid();
var payload = new JsonObject
{
["airgapImpact"] = new JsonObject
{
["bundleId"] = input.BundleId,
["newFindings"] = impact.NewFindings,
["resolvedFindings"] = impact.ResolvedFindings,
["criticalDelta"] = impact.CriticalDelta,
["highDelta"] = impact.HighDelta,
["mediumDelta"] = impact.MediumDelta,
["lowDelta"] = impact.LowDelta,
["timeAnchor"] = input.TimeAnchor.ToString("O"),
["sealedMode"] = input.SealedMode
}
};
var envelope = new JsonObject
{
["event"] = new JsonObject
{
["id"] = eventId.ToString(),
["type"] = LedgerEventConstants.EventAirgapTimelineImpact,
["tenant"] = input.TenantId,
["chainId"] = chainId.ToString(),
["sequence"] = sequence,
["policyVersion"] = "airgap-timeline",
["artifactId"] = input.BundleId,
["finding"] = new JsonObject
{
["id"] = input.BundleId,
["artifactId"] = input.BundleId,
["vulnId"] = "timeline-impact"
},
["actor"] = new JsonObject
{
["id"] = "timeline-service",
["type"] = "system"
},
["occurredAt"] = FormatTimestamp(input.TimeAnchor),
["recordedAt"] = FormatTimestamp(now),
["payload"] = payload.DeepClone()
}
};
var draft = new LedgerEventDraft(
input.TenantId,
chainId,
sequence,
eventId,
LedgerEventConstants.EventAirgapTimelineImpact,
"airgap-timeline",
input.BundleId,
input.BundleId,
SourceRunId: null,
ActorId: "timeline-service",
ActorType: "system",
OccurredAt: input.TimeAnchor,
RecordedAt: now,
Payload: payload,
CanonicalEnvelope: envelope,
ProvidedPreviousHash: previousHash);
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
{
var error = string.Join(";", writeResult.Errors);
return new AirgapTimelineImpactResult(false, null, null, error);
}
var ledgerEventId = writeResult.Record?.EventId;
var finalImpact = impact with { LedgerEventId = ledgerEventId };
// Emit structured log for Console/Notify subscribers
LedgerTimeline.EmitAirgapTimelineImpact(
_logger,
input.TenantId,
input.BundleId,
impact.NewFindings,
impact.ResolvedFindings,
impact.CriticalDelta,
input.TimeAnchor,
input.SealedMode);
return new AirgapTimelineImpactResult(true, finalImpact, ledgerEventId, null);
}
/// <summary>
/// Calculates the impact of a bundle import on findings.
/// </summary>
private async Task<AirgapTimelineImpact> CalculateImpactAsync(
AirgapTimelineImpactInput input,
DateTimeOffset calculatedAt,
CancellationToken cancellationToken)
{
// Query projection repository for finding changes since last import
// For now, we calculate based on current projections updated since the bundle time anchor
var stats = await _projectionRepository.GetFindingStatsSinceAsync(
input.TenantId,
input.TimeAnchor,
cancellationToken).ConfigureAwait(false);
return new AirgapTimelineImpact(
input.TenantId,
input.BundleId,
NewFindings: stats.NewFindings,
ResolvedFindings: stats.ResolvedFindings,
CriticalDelta: stats.CriticalDelta,
HighDelta: stats.HighDelta,
MediumDelta: stats.MediumDelta,
LowDelta: stats.LowDelta,
TimeAnchor: input.TimeAnchor,
SealedMode: input.SealedMode,
CalculatedAt: calculatedAt,
LedgerEventId: null);
}
private static string FormatTimestamp(DateTimeOffset value)
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
}

View File

@@ -0,0 +1,220 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
using StellaOps.Findings.Ledger.Observability;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for linking findings evidence to portable bundles.
/// </summary>
public sealed class EvidenceSnapshotService
{
private readonly ILedgerEventRepository _ledgerEventRepository;
private readonly ILedgerEventWriteService _writeService;
private readonly IEvidenceSnapshotRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EvidenceSnapshotService> _logger;
public EvidenceSnapshotService(
ILedgerEventRepository ledgerEventRepository,
ILedgerEventWriteService writeService,
IEvidenceSnapshotRepository repository,
TimeProvider timeProvider,
ILogger<EvidenceSnapshotService> logger)
{
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Links a finding to an evidence snapshot in a portable bundle.
/// </summary>
public async Task<EvidenceSnapshotLinkResult> LinkAsync(
EvidenceSnapshotLinkInput input,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId);
ArgumentException.ThrowIfNullOrWhiteSpace(input.BundleUri);
ArgumentException.ThrowIfNullOrWhiteSpace(input.DsseDigest);
var now = _timeProvider.GetUtcNow();
var expiresAt = input.ValidFor.HasValue ? now.Add(input.ValidFor.Value) : (DateTimeOffset?)null;
// Check if already linked (idempotency)
var exists = await _repository.ExistsValidAsync(
input.TenantId,
input.FindingId,
input.DsseDigest,
cancellationToken).ConfigureAwait(false);
if (exists)
{
_logger.LogDebug(
"Evidence snapshot already linked for finding {FindingId} with digest {DsseDigest}",
input.FindingId, input.DsseDigest);
return new EvidenceSnapshotLinkResult(true, null, null);
}
// Create ledger event for the linkage
var chainId = LedgerChainIdGenerator.FromTenantSubject(input.TenantId, $"evidence::{input.FindingId}");
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(input.TenantId, chainId, cancellationToken)
.ConfigureAwait(false);
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
var eventId = Guid.NewGuid();
var payload = new JsonObject
{
["airgap"] = new JsonObject
{
["evidenceSnapshot"] = new JsonObject
{
["bundleUri"] = input.BundleUri,
["dsseDigest"] = input.DsseDigest,
["expiresAt"] = expiresAt?.ToString("O")
}
}
};
var envelope = new JsonObject
{
["event"] = new JsonObject
{
["id"] = eventId.ToString(),
["type"] = LedgerEventConstants.EventEvidenceSnapshotLinked,
["tenant"] = input.TenantId,
["chainId"] = chainId.ToString(),
["sequence"] = sequence,
["policyVersion"] = "evidence-snapshot",
["artifactId"] = input.FindingId,
["finding"] = new JsonObject
{
["id"] = input.FindingId,
["artifactId"] = input.FindingId,
["vulnId"] = "evidence-snapshot"
},
["actor"] = new JsonObject
{
["id"] = "evidence-linker",
["type"] = "system"
},
["occurredAt"] = FormatTimestamp(now),
["recordedAt"] = FormatTimestamp(now),
["payload"] = payload.DeepClone()
}
};
var draft = new LedgerEventDraft(
input.TenantId,
chainId,
sequence,
eventId,
LedgerEventConstants.EventEvidenceSnapshotLinked,
"evidence-snapshot",
input.FindingId,
input.FindingId,
SourceRunId: null,
ActorId: "evidence-linker",
ActorType: "system",
OccurredAt: now,
RecordedAt: now,
Payload: payload,
CanonicalEnvelope: envelope,
ProvidedPreviousHash: previousHash);
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
{
var error = string.Join(";", writeResult.Errors);
return new EvidenceSnapshotLinkResult(false, null, error);
}
var ledgerEventId = writeResult.Record?.EventId;
var record = new EvidenceSnapshotRecord(
input.TenantId,
input.FindingId,
input.BundleUri,
input.DsseDigest,
now,
expiresAt,
ledgerEventId);
await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false);
LedgerTimeline.EmitEvidenceSnapshotLinked(_logger, input.TenantId, input.FindingId, input.BundleUri, input.DsseDigest);
return new EvidenceSnapshotLinkResult(true, ledgerEventId, null);
}
/// <summary>
/// Gets evidence snapshots for a finding.
/// </summary>
public async Task<IReadOnlyList<EvidenceSnapshotRecord>> GetSnapshotsAsync(
string tenantId,
string findingId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
return await _repository.GetByFindingIdAsync(tenantId, findingId, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Verifies that an evidence snapshot exists and is valid for cross-enclave verification.
/// </summary>
public async Task<bool> VerifyCrossEnclaveAsync(
string tenantId,
string findingId,
string expectedDsseDigest,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedDsseDigest);
var snapshot = await _repository.GetLatestByFindingIdAsync(tenantId, findingId, cancellationToken)
.ConfigureAwait(false);
if (snapshot is null)
{
_logger.LogWarning(
"No evidence snapshot found for finding {FindingId}",
findingId);
return false;
}
// Check expiration
if (snapshot.ExpiresAt.HasValue && snapshot.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
_logger.LogWarning(
"Evidence snapshot for finding {FindingId} has expired at {ExpiresAt}",
findingId, snapshot.ExpiresAt);
return false;
}
// Verify DSSE digest matches
if (!string.Equals(snapshot.DsseDigest, expectedDsseDigest, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Evidence snapshot DSSE digest mismatch for finding {FindingId}: expected {Expected}, got {Actual}",
findingId, expectedDsseDigest, snapshot.DsseDigest);
return false;
}
return true;
}
private static string FormatTimestamp(DateTimeOffset value)
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
}

View File

@@ -0,0 +1,275 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
using StellaOps.Findings.Ledger.Observability;
using StellaOps.Findings.Ledger.Options;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for validating staleness and enforcing freshness thresholds.
/// </summary>
public sealed class StalenessValidationService
{
private readonly IAirgapImportRepository _importRepository;
private readonly AirGapOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<StalenessValidationService> _logger;
public StalenessValidationService(
IAirgapImportRepository importRepository,
IOptions<AirGapOptions> options,
TimeProvider timeProvider,
ILogger<StalenessValidationService> logger)
{
_importRepository = importRepository ?? throw new ArgumentNullException(nameof(importRepository));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates staleness for a specific domain before allowing an export.
/// </summary>
public async Task<StalenessValidationResult> ValidateForExportAsync(
string tenantId,
string domainId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
// Check if domain is exempt
if (_options.AllowedDomains.Contains(domainId, StringComparer.OrdinalIgnoreCase))
{
return CreatePassedResult(domainId, 0);
}
var latestImport = await _importRepository.GetLatestByDomainAsync(tenantId, domainId, cancellationToken)
.ConfigureAwait(false);
if (latestImport is null)
{
return CreateNoBundleError(domainId);
}
var now = _timeProvider.GetUtcNow();
var stalenessSeconds = (long)(now - latestImport.TimeAnchor).TotalSeconds;
return Validate(domainId, stalenessSeconds, latestImport.TimeAnchor);
}
/// <summary>
/// Validates staleness using an explicit staleness value.
/// </summary>
public StalenessValidationResult Validate(
string? domainId,
long stalenessSeconds,
DateTimeOffset? timeAnchor = null)
{
var warnings = new List<StalenessWarning>();
var thresholdSeconds = _options.FreshnessThresholdSeconds;
var percentOfThreshold = (double)stalenessSeconds / thresholdSeconds * 100.0;
// Check notification thresholds for warnings
foreach (var threshold in _options.NotificationThresholds.OrderBy(t => t.PercentOfThreshold))
{
if (percentOfThreshold >= threshold.PercentOfThreshold && percentOfThreshold < 100)
{
var projectedStaleAt = timeAnchor?.AddSeconds(thresholdSeconds);
warnings.Add(new StalenessWarning(
StalenessWarningCode.WarnAirgapApproachingStale,
$"Data is {percentOfThreshold:F1}% of staleness threshold ({threshold.Severity})",
percentOfThreshold,
projectedStaleAt));
}
}
// Check if stale
if (stalenessSeconds > thresholdSeconds)
{
var actualThresholdWithGrace = thresholdSeconds + _options.GracePeriodSeconds;
var isInGracePeriod = stalenessSeconds <= actualThresholdWithGrace;
if (_options.EnforcementMode == StalenessEnforcementMode.Disabled)
{
return CreatePassedResult(domainId, stalenessSeconds, warnings);
}
if (_options.EnforcementMode == StalenessEnforcementMode.Warn || isInGracePeriod)
{
warnings.Add(new StalenessWarning(
StalenessWarningCode.WarnAirgapBundleOld,
$"Data is stale ({stalenessSeconds / 86400.0:F1} days old, threshold {thresholdSeconds / 86400.0:F0} days)",
percentOfThreshold,
null));
// Emit metric
if (_options.EmitMetrics)
{
LedgerMetrics.RecordAirgapStaleness(domainId, stalenessSeconds);
}
return CreatePassedResult(domainId, stalenessSeconds, warnings);
}
// Strict enforcement - block the export
var error = new StalenessError(
StalenessErrorCode.ErrAirgapStale,
$"Data is stale ({stalenessSeconds / 86400.0:F1} days old, threshold {thresholdSeconds / 86400.0:F0} days)",
domainId,
stalenessSeconds,
thresholdSeconds,
$"Import a fresh bundle from upstream using 'stella airgap import --domain {domainId}'");
_logger.LogWarning(
"Staleness validation failed for domain {DomainId}: {StalenessSeconds}s > {ThresholdSeconds}s",
domainId, stalenessSeconds, thresholdSeconds);
// Emit metric
if (_options.EmitMetrics)
{
LedgerMetrics.RecordAirgapStaleness(domainId, stalenessSeconds);
LedgerMetrics.RecordStalenessValidationFailure(domainId);
}
return new StalenessValidationResult(
false,
domainId,
stalenessSeconds,
thresholdSeconds,
_options.EnforcementMode,
error,
warnings);
}
// Emit metric for healthy staleness
if (_options.EmitMetrics)
{
LedgerMetrics.RecordAirgapStaleness(domainId, stalenessSeconds);
}
return CreatePassedResult(domainId, stalenessSeconds, warnings);
}
/// <summary>
/// Collects staleness metrics for all domains in a tenant.
/// </summary>
public async Task<StalenessMetricsSnapshot> CollectMetricsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var thresholdSeconds = _options.FreshnessThresholdSeconds;
var imports = await _importRepository.GetAllLatestByDomainAsync(tenantId, cancellationToken)
.ConfigureAwait(false);
var domainMetrics = new List<DomainStalenessMetric>();
var staleDomains = 0;
var warningDomains = 0;
var healthyDomains = 0;
var totalStaleness = 0L;
var maxStaleness = 0L;
DateTimeOffset? oldestBundle = null;
foreach (var import in imports)
{
var stalenessSeconds = (long)(now - import.TimeAnchor).TotalSeconds;
var percentOfThreshold = (double)stalenessSeconds / thresholdSeconds * 100.0;
var isStale = stalenessSeconds > thresholdSeconds;
var projectedStaleAt = import.TimeAnchor.AddSeconds(thresholdSeconds);
if (isStale)
{
staleDomains++;
}
else if (percentOfThreshold >= 75)
{
warningDomains++;
}
else
{
healthyDomains++;
}
totalStaleness += stalenessSeconds;
maxStaleness = Math.Max(maxStaleness, stalenessSeconds);
if (oldestBundle is null || import.TimeAnchor < oldestBundle)
{
oldestBundle = import.TimeAnchor;
}
var bundleCount = await _importRepository.GetBundleCountByDomainAsync(tenantId, import.BundleId, cancellationToken)
.ConfigureAwait(false);
domainMetrics.Add(new DomainStalenessMetric(
import.BundleId, // Using BundleId as domain since we don't have domain in the record
stalenessSeconds,
import.ImportedAt,
import.TimeAnchor,
bundleCount,
isStale,
percentOfThreshold,
isStale ? null : projectedStaleAt));
// Emit per-domain metric
if (_options.EmitMetrics)
{
LedgerMetrics.RecordAirgapStaleness(import.BundleId, stalenessSeconds);
}
}
var totalDomains = domainMetrics.Count;
var avgStaleness = totalDomains > 0 ? (double)totalStaleness / totalDomains : 0.0;
var aggregates = new AggregateStalenessMetrics(
totalDomains,
staleDomains,
warningDomains,
healthyDomains,
maxStaleness,
avgStaleness,
oldestBundle);
return new StalenessMetricsSnapshot(now, tenantId, domainMetrics, aggregates);
}
private StalenessValidationResult CreatePassedResult(
string? domainId,
long stalenessSeconds,
IReadOnlyList<StalenessWarning>? warnings = null)
{
return new StalenessValidationResult(
true,
domainId,
stalenessSeconds,
_options.FreshnessThresholdSeconds,
_options.EnforcementMode,
null,
warnings ?? Array.Empty<StalenessWarning>());
}
private StalenessValidationResult CreateNoBundleError(string domainId)
{
var error = new StalenessError(
StalenessErrorCode.ErrAirgapNoBundle,
$"No bundle found for domain '{domainId}'",
domainId,
0,
_options.FreshnessThresholdSeconds,
$"Import a bundle using 'stella airgap import --domain {domainId}'");
return new StalenessValidationResult(
false,
domainId,
0,
_options.FreshnessThresholdSeconds,
_options.EnforcementMode,
error,
Array.Empty<StalenessWarning>());
}
}

View File

@@ -6,7 +6,7 @@
| LEDGER-34-101 | DONE | Orchestrator export linkage | 2025-11-22 |
| LEDGER-AIRGAP-56-001 | DONE | Mirror bundle provenance recording | 2025-11-22 |
Status changes must be mirrored in `docs/implplan/SPRINT_0120_0000_0001_policy_reasoning.md`.
Status changes must be mirrored in `docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md`.
# Findings Ledger · Sprint 0121-0001-0001