feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies. - Documented roles and guidelines in AGENTS.md for Scheduler module. - Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs. - Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics. - Developed API endpoints for managing resolver jobs and retrieving metrics. - Defined models for resolver job requests and responses. - Integrated dependency injection for resolver job services. - Implemented ImpactIndexSnapshot for persisting impact index data. - Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring. - Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService. - Created dotnet-filter.sh script to handle command-line arguments for dotnet. - Established nuget-prime project for managing package downloads.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
public sealed record EvidenceReference(
|
||||
Guid EventId,
|
||||
string EvidenceBundleRef,
|
||||
DateTimeOffset RecordedAt)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public string EvidenceBundleRefNormalized => EvidenceBundleRef.Trim();
|
||||
}
|
||||
@@ -18,7 +18,8 @@ public sealed record LedgerEventDraft(
|
||||
DateTimeOffset RecordedAt,
|
||||
JsonObject Payload,
|
||||
JsonObject CanonicalEnvelope,
|
||||
string? ProvidedPreviousHash);
|
||||
string? ProvidedPreviousHash,
|
||||
string? EvidenceBundleReference = null);
|
||||
|
||||
public sealed record LedgerEventRecord(
|
||||
string TenantId,
|
||||
@@ -38,7 +39,8 @@ public sealed record LedgerEventRecord(
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
string MerkleLeafHash,
|
||||
string CanonicalJson);
|
||||
string CanonicalJson,
|
||||
string? EvidenceBundleReference = null);
|
||||
|
||||
public sealed record LedgerChainHead(
|
||||
long SequenceNumber,
|
||||
|
||||
@@ -10,4 +10,6 @@ public interface ILedgerEventRepository
|
||||
Task<LedgerChainHead?> GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken);
|
||||
|
||||
Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,19 @@ public sealed class InMemoryLedgerEventRepository : ILedgerEventRepository
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var matches = _events.Values
|
||||
.Where(e => e.TenantId == tenantId
|
||||
&& string.Equals(e.FindingId, findingId, StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(e.EvidenceBundleReference))
|
||||
.OrderByDescending(e => e.RecordedAt)
|
||||
.Select(e => new EvidenceReference(e.EventId, e.EvidenceBundleReference!, e.RecordedAt))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EvidenceReference>>(matches);
|
||||
}
|
||||
|
||||
private static LedgerEventRecord Clone(LedgerEventRecord record)
|
||||
{
|
||||
var clonedBody = (JsonObject)record.EventBody.DeepClone();
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND event_id = @event_id
|
||||
@@ -59,7 +60,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash)
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@chain_id,
|
||||
@@ -77,7 +79,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
@event_body,
|
||||
@event_hash,
|
||||
@previous_hash,
|
||||
@merkle_leaf_hash)
|
||||
@merkle_leaf_hash,
|
||||
@evidence_bundle_ref)
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
@@ -162,6 +165,7 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
command.Parameters.AddWithValue("event_hash", record.EventHash);
|
||||
command.Parameters.AddWithValue("previous_hash", record.PreviousHash);
|
||||
command.Parameters.AddWithValue("merkle_leaf_hash", record.MerkleLeafHash);
|
||||
command.Parameters.AddWithValue("evidence_bundle_ref", (object?)record.EvidenceBundleReference ?? DBNull.Value);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -194,6 +198,7 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
var eventHash = reader.GetString(12);
|
||||
var previousHash = reader.GetString(13);
|
||||
var merkleLeafHash = reader.GetString(14);
|
||||
var evidenceBundleRef = reader.IsDBNull(15) ? null : reader.GetString(15);
|
||||
|
||||
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBody);
|
||||
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonicalEnvelope);
|
||||
@@ -216,6 +221,37 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
eventHash,
|
||||
previousHash,
|
||||
merkleLeafHash,
|
||||
canonicalJson);
|
||||
canonicalJson,
|
||||
evidenceBundleRef);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT event_id, evidence_bundle_ref, recorded_at
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND finding_id = @finding_id
|
||||
AND evidence_bundle_ref IS NOT NULL
|
||||
ORDER BY recorded_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<EvidenceReference>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new EvidenceReference(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetFieldValue<DateTimeOffset>(2)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -5,6 +6,7 @@ using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Projection;
|
||||
@@ -74,9 +76,21 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
|
||||
foreach (var record in batch)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["tenant"] = record.TenantId,
|
||||
["chainId"] = record.ChainId,
|
||||
["eventId"] = record.EventId,
|
||||
["eventType"] = record.EventType,
|
||||
["policyVersion"] = record.PolicyVersion
|
||||
});
|
||||
using var activity = LedgerTelemetry.StartProjectionApply(record);
|
||||
var applyStopwatch = Stopwatch.StartNew();
|
||||
string? evaluationStatus = null;
|
||||
|
||||
try
|
||||
{
|
||||
await ApplyAsync(record, stoppingToken).ConfigureAwait(false);
|
||||
evaluationStatus = await ApplyAsync(record, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
checkpoint = checkpoint with
|
||||
{
|
||||
@@ -86,13 +100,36 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
};
|
||||
|
||||
await _repository.SaveCheckpointAsync(checkpoint, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Projected ledger event {EventId} for tenant {Tenant} chain {ChainId} seq {Sequence} finding {FindingId}.",
|
||||
record.EventId,
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.FindingId);
|
||||
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok);
|
||||
|
||||
applyStopwatch.Stop();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var lagSeconds = Math.Max(0, (now - record.RecordedAt).TotalSeconds);
|
||||
LedgerMetrics.RecordProjectionApply(
|
||||
applyStopwatch.Elapsed,
|
||||
lagSeconds,
|
||||
record.TenantId,
|
||||
record.EventType,
|
||||
record.PolicyVersion,
|
||||
evaluationStatus ?? string.Empty);
|
||||
LedgerTimeline.EmitProjectionUpdated(_logger, record, evaluationStatus, evidenceBundleRef: null);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "projection_cancelled");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "projection_failed");
|
||||
_logger.LogError(ex, "Failed to project ledger event {EventId} for tenant {TenantId}.", record.EventId, record.TenantId);
|
||||
await DelayAsync(stoppingToken).ConfigureAwait(false);
|
||||
break;
|
||||
@@ -101,7 +138,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(LedgerEventRecord record, CancellationToken cancellationToken)
|
||||
private async Task<string?> ApplyAsync(LedgerEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await _repository.GetAsync(record.TenantId, record.FindingId, record.PolicyVersion, cancellationToken).ConfigureAwait(false);
|
||||
var evaluation = await _policyEvaluationService.EvaluateAsync(record, current, cancellationToken).ConfigureAwait(false);
|
||||
@@ -114,6 +151,8 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
{
|
||||
await _repository.InsertActionAsync(result.Action, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return evaluation.Status;
|
||||
}
|
||||
|
||||
private async Task DelayAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -15,6 +15,20 @@ internal static class LedgerMetrics
|
||||
"ledger_events_total",
|
||||
description: "Number of ledger events appended.");
|
||||
|
||||
private static readonly Histogram<double> ProjectionApplySeconds = Meter.CreateHistogram<double>(
|
||||
"ledger_projection_apply_seconds",
|
||||
unit: "s",
|
||||
description: "Duration to apply a ledger event to the finding projection.");
|
||||
|
||||
private static readonly Histogram<double> ProjectionLagSeconds = Meter.CreateHistogram<double>(
|
||||
"ledger_projection_lag_seconds",
|
||||
unit: "s",
|
||||
description: "Lag between ledger recorded_at and projection application time.");
|
||||
|
||||
private static readonly Counter<long> ProjectionEventsTotal = Meter.CreateCounter<long>(
|
||||
"ledger_projection_events_total",
|
||||
description: "Number of ledger events applied to projections.");
|
||||
|
||||
public static void RecordWriteSuccess(TimeSpan duration, string? tenantId, string? eventType, string? source)
|
||||
{
|
||||
var tags = new TagList
|
||||
@@ -27,4 +41,25 @@ internal static class LedgerMetrics
|
||||
WriteLatencySeconds.Record(duration.TotalSeconds, tags);
|
||||
EventsTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
public static void RecordProjectionApply(
|
||||
TimeSpan duration,
|
||||
double lagSeconds,
|
||||
string? tenantId,
|
||||
string? eventType,
|
||||
string? policyVersion,
|
||||
string? evaluationStatus)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", tenantId ?? string.Empty },
|
||||
{ "event_type", eventType ?? string.Empty },
|
||||
{ "policy_version", policyVersion ?? string.Empty },
|
||||
{ "evaluation_status", evaluationStatus ?? string.Empty }
|
||||
};
|
||||
|
||||
ProjectionApplySeconds.Record(duration.TotalSeconds, tags);
|
||||
ProjectionLagSeconds.Record(lagSeconds, tags);
|
||||
ProjectionEventsTotal.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised ActivitySource and tagging helpers for ledger telemetry.
|
||||
/// Keeps tags consistent across writer, projector, and query surfaces.
|
||||
/// </summary>
|
||||
internal static class LedgerTelemetry
|
||||
{
|
||||
internal const string ActivitySourceName = "StellaOps.Findings.Ledger";
|
||||
|
||||
private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
public static Activity? StartLedgerAppend(LedgerEventDraft draft)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Ledger.Append", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("tenant", draft.TenantId);
|
||||
activity.SetTag("chain_id", draft.ChainId);
|
||||
activity.SetTag("sequence", draft.SequenceNumber);
|
||||
activity.SetTag("event_id", draft.EventId);
|
||||
activity.SetTag("event_type", draft.EventType);
|
||||
activity.SetTag("actor_id", draft.ActorId);
|
||||
activity.SetTag("actor_type", draft.ActorType);
|
||||
activity.SetTag("policy_version", draft.PolicyVersion);
|
||||
activity.SetTag("source", draft.SourceRunId.HasValue ? "policy_run" : "workflow");
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static void MarkAppendOutcome(Activity? activity, LedgerEventRecord record, TimeSpan duration)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("event_hash", record.EventHash);
|
||||
activity.SetTag("previous_hash", record.PreviousHash);
|
||||
activity.SetTag("merkle_leaf_hash", record.MerkleLeafHash);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
public static void MarkError(Activity? activity, string reason)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Error, reason);
|
||||
}
|
||||
|
||||
public static Activity? StartProjectionApply(LedgerEventRecord record)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Ledger.Projection.Apply", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("tenant", record.TenantId);
|
||||
activity.SetTag("chain_id", record.ChainId);
|
||||
activity.SetTag("sequence", record.SequenceNumber);
|
||||
activity.SetTag("event_id", record.EventId);
|
||||
activity.SetTag("event_type", record.EventType);
|
||||
activity.SetTag("policy_version", record.PolicyVersion);
|
||||
activity.SetTag("finding_id", record.FindingId);
|
||||
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured timeline events for ledger operations.
|
||||
/// Currently materialised as structured logs; can be swapped for event sink later.
|
||||
/// </summary>
|
||||
internal static class LedgerTimeline
|
||||
{
|
||||
private static readonly EventId LedgerAppended = new(6101, "ledger.event.appended");
|
||||
private static readonly EventId ProjectionUpdated = new(6201, "ledger.projection.updated");
|
||||
|
||||
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToHexString() ?? string.Empty;
|
||||
|
||||
logger.LogInformation(
|
||||
LedgerAppended,
|
||||
"timeline ledger.event.appended tenant={Tenant} chain={ChainId} seq={Sequence} event={EventId} type={EventType} policy={PolicyVersion} finding={FindingId} trace={TraceId} evidence_ref={EvidenceRef}",
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.EventId,
|
||||
record.EventType,
|
||||
record.PolicyVersion,
|
||||
record.FindingId,
|
||||
traceId,
|
||||
evidenceBundleRef ?? record.EvidenceBundleReference ?? string.Empty);
|
||||
}
|
||||
|
||||
public static void EmitProjectionUpdated(
|
||||
ILogger logger,
|
||||
LedgerEventRecord record,
|
||||
string? evaluationStatus,
|
||||
string? evidenceBundleRef = null)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToHexString() ?? string.Empty;
|
||||
|
||||
logger.LogInformation(
|
||||
ProjectionUpdated,
|
||||
"timeline ledger.projection.updated tenant={Tenant} chain={ChainId} seq={Sequence} event={EventId} policy={PolicyVersion} finding={FindingId} status={Status} trace={TraceId} evidence_ref={EvidenceRef}",
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.EventId,
|
||||
record.PolicyVersion,
|
||||
record.FindingId,
|
||||
evaluationStatus ?? string.Empty,
|
||||
traceId,
|
||||
evidenceBundleRef ?? record.EvidenceBundleReference ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,21 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
public async Task<LedgerWriteResult> AppendAsync(LedgerEventDraft draft, CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var activity = LedgerTelemetry.StartLedgerAppend(draft);
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["tenant"] = draft.TenantId,
|
||||
["chainId"] = draft.ChainId,
|
||||
["sequence"] = draft.SequenceNumber,
|
||||
["eventId"] = draft.EventId,
|
||||
["eventType"] = draft.EventType,
|
||||
["policyVersion"] = draft.PolicyVersion
|
||||
});
|
||||
|
||||
var validationErrors = ValidateDraft(draft);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "validation_failed");
|
||||
return LedgerWriteResult.ValidationFailed([.. validationErrors]);
|
||||
}
|
||||
|
||||
@@ -45,6 +56,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(draft.CanonicalEnvelope);
|
||||
if (!string.Equals(existing.CanonicalJson, canonicalJson, StringComparison.Ordinal))
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "event_id_conflict");
|
||||
return LedgerWriteResult.Conflict(
|
||||
"event_id_conflict",
|
||||
$"Event '{draft.EventId}' already exists with a different payload.");
|
||||
@@ -58,6 +70,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var expectedSequence = chainHead is null ? 1 : chainHead.SequenceNumber + 1;
|
||||
if (draft.SequenceNumber != expectedSequence)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "sequence_mismatch");
|
||||
return LedgerWriteResult.Conflict(
|
||||
"sequence_mismatch",
|
||||
$"Sequence number '{draft.SequenceNumber}' does not match expected '{expectedSequence}'.");
|
||||
@@ -66,6 +79,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
if (draft.ProvidedPreviousHash is not null && !string.Equals(draft.ProvidedPreviousHash, previousHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "previous_hash_mismatch");
|
||||
return LedgerWriteResult.Conflict(
|
||||
"previous_hash_mismatch",
|
||||
$"Provided previous hash '{draft.ProvidedPreviousHash}' does not match chain head hash '{previousHash}'.");
|
||||
@@ -93,7 +107,8 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
hashResult.EventHash,
|
||||
previousHash,
|
||||
hashResult.MerkleLeafHash,
|
||||
hashResult.CanonicalJson);
|
||||
hashResult.CanonicalJson,
|
||||
draft.EvidenceBundleReference);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -102,10 +117,29 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
|
||||
stopwatch.Stop();
|
||||
LedgerMetrics.RecordWriteSuccess(stopwatch.Elapsed, draft.TenantId, draft.EventType, DetermineSource(draft));
|
||||
LedgerTelemetry.MarkAppendOutcome(activity, record, stopwatch.Elapsed);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ledger append committed for tenant {Tenant} chain {ChainId} seq {Sequence} event {EventId} ({EventType}) hash {Hash} prev {PrevHash}.",
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.EventId,
|
||||
record.EventType,
|
||||
record.EventHash,
|
||||
record.PreviousHash);
|
||||
LedgerTimeline.EmitLedgerAppended(_logger, record, evidenceBundleRef: null);
|
||||
}
|
||||
catch (Exception ex) when (IsDuplicateKeyException(ex))
|
||||
{
|
||||
_logger.LogWarning(ex, "Ledger append detected concurrent duplicate for {EventId}", draft.EventId);
|
||||
LedgerTelemetry.MarkError(activity, "duplicate_event");
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Ledger append detected concurrent duplicate for tenant {Tenant} chain {ChainId} seq {Sequence} event {EventId}.",
|
||||
draft.TenantId,
|
||||
draft.ChainId,
|
||||
draft.SequenceNumber,
|
||||
draft.EventId);
|
||||
var persisted = await _repository.GetByEventIdAsync(draft.TenantId, draft.EventId, cancellationToken).ConfigureAwait(false);
|
||||
if (persisted is null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- LEDGER-OBS-53-001: persist evidence bundle references alongside ledger entries.
|
||||
|
||||
ALTER TABLE ledger_events
|
||||
ADD COLUMN evidence_bundle_ref text NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_events_finding_evidence_ref
|
||||
ON ledger_events (tenant_id, finding_id, recorded_at DESC)
|
||||
WHERE evidence_bundle_ref IS NOT NULL;
|
||||
Reference in New Issue
Block a user