From 2f32c7f0c20f4aeb380b7a26dc1f5d3412ea3df6 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 19 Apr 2026 22:42:39 +0300 Subject: [PATCH] feat(jobengine): dual-write audit entries to Timeline unified sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint SPRINT_20260408_005 DEPRECATE-001 (JobEngine/ReleaseOrchestrator, fifth service). PostgresAuditRepository.AppendAsync now fans out to Timeline via the optional IAuditEventEmitter after the local transaction commits. The hash chain (content_hash, previous_entry_hash, sequence_number) stays in the local audit_entries table as service-level chain-of-custody evidence; Timeline receives only the summary event for cross-service correlation, with the content hash surfaced as a detail field. Same pattern as Authority/Policy/Notify/Scheduler dual-write: fire-and-forget, optional DI, local write stays authoritative. Remaining: Attestor dual-write (existing audit is already decorated with .Audited() on endpoints — verifying the attestor audit log insert path needs separate review). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Postgres/PostgresAuditRepository.cs | 68 ++++++++++++++++++- ...Ops.ReleaseOrchestrator.Persistence.csproj | 1 + 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs index 1afcfc55d..3caaaa19b 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Postgres/PostgresAuditRepository.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Audit.Emission; using StellaOps.ReleaseOrchestrator.Persistence.Domain; using StellaOps.ReleaseOrchestrator.Persistence.Hashing; using StellaOps.ReleaseOrchestrator.Persistence.Repositories; @@ -98,17 +99,20 @@ public sealed class PostgresAuditRepository : IAuditRepository private readonly CanonicalJsonHasher _hasher; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; + private readonly IAuditEventEmitter? _timelineEmitter; public PostgresAuditRepository( ReleaseOrchestratorDataSource dataSource, CanonicalJsonHasher hasher, ILogger logger, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IAuditEventEmitter? timelineEmitter = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; + _timelineEmitter = timelineEmitter; } public async Task AppendAsync( @@ -198,6 +202,21 @@ public sealed class PostgresAuditRepository : IAuditRepository _logger.LogDebug("Audit entry {EntryId} appended for tenant {TenantId}, sequence {Sequence}", entry.EntryId, tenantId, sequenceNumber); + // DEPRECATE-001: dual-write to Timeline. Fire-and-forget; hash chain is + // service-level evidence that stays local (not emitted) — only the audit + // event summary goes to Timeline for cross-service correlation. + if (_timelineEmitter is not null) + { + try + { + await _timelineEmitter.EmitAsync(MapToTimelinePayload(entry), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to emit jobengine audit event to Timeline (local write succeeded, entryId={EntryId})", entry.EntryId); + } + } + return entry; } catch @@ -207,6 +226,53 @@ public sealed class PostgresAuditRepository : IAuditRepository } } + private static AuditEventPayload MapToTimelinePayload(AuditEntry entry) + { + var details = new Dictionary(StringComparer.Ordinal) + { + ["localEntryId"] = entry.EntryId, + ["sequenceNumber"] = entry.SequenceNumber, + ["contentHash"] = entry.ContentHash, + ["httpMethod"] = entry.HttpMethod, + ["requestPath"] = entry.RequestPath + }; + if (!string.IsNullOrWhiteSpace(entry.OldState)) + { + details["oldState"] = entry.OldState; + } + if (!string.IsNullOrWhiteSpace(entry.NewState)) + { + details["newState"] = entry.NewState; + } + + return new AuditEventPayload + { + Id = $"jobengine-{entry.EntryId}", + Timestamp = entry.OccurredAt, + Module = "jobengine", + Action = entry.EventType.ToString().ToLowerInvariant(), + Severity = "info", + Actor = new AuditActorPayload + { + Id = entry.ActorId ?? "jobengine-system", + Name = entry.ActorId ?? "jobengine-system", + Type = entry.ActorType.ToString().ToLowerInvariant(), + IpAddress = entry.ActorIp, + UserAgent = entry.UserAgent + }, + Resource = new AuditResourcePayload + { + Type = entry.ResourceType ?? "jobengine_resource", + Id = entry.ResourceId.ToString() + }, + Description = entry.Description ?? entry.EventType.ToString(), + Details = details, + CorrelationId = entry.CorrelationId, + TenantId = entry.TenantId, + Tags = new[] { "jobengine", entry.EventType.ToString().ToLowerInvariant() } + }; + } + public async Task GetByIdAsync( string tenantId, Guid entryId, diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/StellaOps.ReleaseOrchestrator.Persistence.csproj b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/StellaOps.ReleaseOrchestrator.Persistence.csproj index ccee21a21..02afb28db 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/StellaOps.ReleaseOrchestrator.Persistence.csproj +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/StellaOps.ReleaseOrchestrator.Persistence.csproj @@ -27,6 +27,7 @@ +