feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 12:37:50 -04:00
parent 5aaf9e2923
commit db707bb0de
127 changed files with 2240 additions and 3886 deletions
@@ -1,137 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
/// <summary>
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
/// site rows leave IngestedAtUtc null until ingest. Append-only.
/// </summary>
/// <remarks>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
/// <c>datetime2</c> column shape required by the AuditLog table.
/// </remarks>
public sealed record AuditEvent
{
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
/// <summary>
/// UTC timestamp when the audited action occurred at its source. The value
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
/// <c>datetime2</c> read where the value bypassed the EF converter) is
/// re-tagged as UTC rather than treated as local time downstream. Producers
/// are still expected to supply values that ARE genuinely UTC — the setter
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
/// </summary>
public DateTime OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
private readonly DateTime _occurredAtUtc;
/// <summary>
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
/// The value MUST be in UTC when non-null; the init-setter forces
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
/// <see cref="OccurredAtUtc"/>'s contract.
/// </summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
init => _ingestedAtUtc = value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
/// run was spawned by another; null for top-level runs. Lets a spawned
/// execution point back at its spawner for cross-run correlation.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Stamped by the writing node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
/// has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -7,6 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
/// Failures must NEVER abort the user-facing action.
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the event type is the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>.
/// The local seam is retained (rather than collapsed onto <c>ZB.MOM.WW.Audit.IAuditWriter</c>)
/// so it stays a distinct DI binding from <see cref="ICentralAuditWriter"/> and so the many
/// existing site/central implementations and test fakes keep their identity.
/// </remarks>
public interface IAuditWriter
{
/// <summary>
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
/// </summary>
/// <remarks>
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
/// AuditLog-001: cached-lifecycle audit kinds
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
/// <summary>
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
/// whose audit kind belongs to the cached-call lifecycle
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -1,3 +1,4 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -0,0 +1,193 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Transitional canonical ⇄ 24-column shim for the two AuditLog storage
/// implementations (site SQLite, central SQL Server). C3 keeps the existing
/// 24-column tables UNCHANGED; this helper decomposes a canonical
/// <see cref="ZB.MOM.WW.Audit.AuditEvent"/> into the typed domain values the
/// columns expect (Channel/Kind/Status enums + the <see cref="AuditDetails"/>
/// fields) and recomposes a canonical record from those column values.
/// </summary>
/// <remarks>
/// <para>
/// C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical record
/// only carries Action/Category/Outcome at the top level and stashes every
/// ScadaBridge domain field inside <c>DetailsJson</c>; the legacy storage rows
/// carry the domain fields as typed columns. This shim bridges the two without
/// any schema change. C4 replaces the site shim with the real DetailsJson
/// schema; C5 the central one.
/// </para>
/// <para>
/// <c>ForwardState</c> is deliberately NOT part of this projection — it is a
/// site-storage-only concern carried alongside the canonical record by the site
/// SQLite writer, never inside <c>DetailsJson</c> and never on a central row.
/// </para>
/// </remarks>
public static class AuditRowProjection
{
/// <summary>
/// The decomposed domain view of a canonical <see cref="AuditEvent"/> — the
/// values the 24 storage columns expect. Built by <see cref="Decompose"/> from
/// the canonical top-level fields plus the <see cref="AuditDetails"/> bag.
/// </summary>
public readonly record struct AuditRowValues(
Guid EventId,
DateTime OccurredAtUtc,
DateTime? IngestedAtUtc,
AuditChannel Channel,
AuditKind Kind,
AuditStatus Status,
Guid? CorrelationId,
Guid? ExecutionId,
Guid? ParentExecutionId,
string? SourceSiteId,
string? SourceNode,
string? SourceInstanceId,
string? SourceScript,
string? Actor,
string? Target,
int? HttpStatus,
int? DurationMs,
string? ErrorMessage,
string? ErrorDetail,
string? RequestSummary,
string? ResponseSummary,
bool PayloadTruncated,
string? Extra);
/// <summary>
/// Decomposes a canonical record into the typed column values. Channel/Kind/Status
/// come from <c>DetailsJson</c> (the strings written by
/// <see cref="ScadaBridgeAuditEventFactory"/>); a missing/unparseable discriminator
/// falls back to the first enum member (defensive — production rows always carry them).
/// </summary>
public static AuditRowValues Decompose(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
var channel = ParseEnum(d.Channel, AuditChannel.ApiInbound);
var kind = ParseEnum(d.Kind, AuditKind.InboundRequest);
var status = ParseEnum(d.Status, AuditStatus.Submitted);
// The canonical OccurredAtUtc is UTC by construction; columns store a
// Kind=Utc DateTime so downstream UTC/local conversions are safe
// (CLAUDE.md: "All timestamps are UTC throughout the system.").
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
DateTime? ingested = d.IngestedAtUtc.HasValue
? DateTime.SpecifyKind(d.IngestedAtUtc.Value.UtcDateTime, DateTimeKind.Utc)
: null;
return new AuditRowValues(
EventId: evt.EventId,
OccurredAtUtc: occurred,
IngestedAtUtc: ingested,
Channel: channel,
Kind: kind,
Status: status,
CorrelationId: evt.CorrelationId,
ExecutionId: d.ExecutionId,
ParentExecutionId: d.ParentExecutionId,
SourceSiteId: d.SourceSiteId,
SourceNode: evt.SourceNode,
SourceInstanceId: d.SourceInstanceId,
SourceScript: d.SourceScript,
// Canonical Actor is a required non-null string; an empty Actor maps
// back to a NULL column (legacy rows stored null for system/anon).
Actor: string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
Target: evt.Target,
HttpStatus: d.HttpStatus,
DurationMs: d.DurationMs,
ErrorMessage: d.ErrorMessage,
ErrorDetail: d.ErrorDetail,
RequestSummary: d.RequestSummary,
ResponseSummary: d.ResponseSummary,
PayloadTruncated: d.PayloadTruncated,
Extra: d.Extra);
}
/// <summary>
/// Recomposes a canonical <see cref="AuditEvent"/> from the typed column values read
/// back from storage. The inverse of <see cref="Decompose"/>: Action/Category/Outcome
/// are rebuilt via the field builders / outcome projector, and every domain field is
/// re-serialized into <c>DetailsJson</c> via <see cref="AuditDetailsCodec"/>.
/// </summary>
public static AuditEvent Recompose(in AuditRowValues v)
{
var details = new AuditDetails
{
Channel = v.Channel.ToString(),
Kind = v.Kind.ToString(),
Status = v.Status.ToString(),
ExecutionId = v.ExecutionId,
ParentExecutionId = v.ParentExecutionId,
SourceSiteId = v.SourceSiteId,
SourceInstanceId = v.SourceInstanceId,
SourceScript = v.SourceScript,
HttpStatus = v.HttpStatus,
DurationMs = v.DurationMs,
ErrorMessage = v.ErrorMessage,
ErrorDetail = v.ErrorDetail,
RequestSummary = v.RequestSummary,
ResponseSummary = v.ResponseSummary,
PayloadTruncated = v.PayloadTruncated,
Extra = v.Extra,
IngestedAtUtc = v.IngestedAtUtc.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(v.IngestedAtUtc.Value, DateTimeKind.Utc))
: null,
};
return new AuditEvent
{
EventId = v.EventId,
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(v.OccurredAtUtc, DateTimeKind.Utc)),
Actor = v.Actor ?? string.Empty,
Action = AuditFieldBuilders.BuildAction(v.Channel, v.Kind),
Category = AuditFieldBuilders.BuildCategory(v.Channel),
Outcome = AuditOutcomeProjector.Project(v.Status, v.Kind),
Target = v.Target,
SourceNode = v.SourceNode,
CorrelationId = v.CorrelationId,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
/// <summary>
/// Returns a copy of <paramref name="evt"/> with the central-side ingest timestamp
/// stamped into its <c>DetailsJson</c> (<see cref="AuditDetails.IngestedAtUtc"/>).
/// C3 transitional shim: <c>IngestedAtUtc</c> is a DetailsJson field on the canonical
/// record, so the central ingest paths stamp it here rather than on a top-level
/// property as the legacy bespoke record allowed.
/// </summary>
public static AuditEvent WithIngestedAtUtc(AuditEvent evt, DateTimeOffset ingestedAtUtc)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson) with
{
IngestedAtUtc = ingestedAtUtc.ToUniversalTime(),
};
return evt with { DetailsJson = AuditDetailsCodec.Serialize(d) };
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct, Enum
=> !string.IsNullOrEmpty(value) && Enum.TryParse<TEnum>(value, ignoreCase: false, out var parsed)
? parsed
: fallback;
}
/// <summary>
/// Convenience extension that decomposes a canonical <see cref="AuditEvent"/> into its
/// typed 24-field <see cref="AuditRowProjection.AuditRowValues"/> view. Lets callers
/// (and tests) read the ScadaBridge domain fields — Channel/Kind/Status + the DetailsJson
/// fields — as typed properties off a canonical row.
/// </summary>
public static class AuditEventRowExtensions
{
/// <summary>Decomposes this canonical record into its typed 24-field view.</summary>
public static AuditRowProjection.AuditRowValues AsRow(this AuditEvent evt)
=> AuditRowProjection.Decompose(evt);
}
@@ -0,0 +1,125 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Single construction point for the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>
/// from ScadaBridge's domain vocabulary. Every emit site builds its row through
/// <see cref="Create"/> so the canonical-field mapping (Channel/Kind/Status →
/// Action/Category/Outcome, every other domain field → <see cref="AuditDetails"/>
/// inside <see cref="ZB.MOM.WW.Audit.AuditEvent.DetailsJson"/>) is applied
/// identically everywhere — no per-site drift.
/// </summary>
/// <remarks>
/// <para>C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical
/// record is the type at every seam, emit site, DTO boundary, and redactor; the
/// ScadaBridge domain fields ride in <c>DetailsJson</c> via
/// <see cref="AuditDetailsCodec"/>.</para>
/// <para>Mapping (see Task 2.5 spec):
/// <list type="bullet">
/// <item><c>Action</c> = <see cref="AuditFieldBuilders.BuildAction"/>(channel, kind).</item>
/// <item><c>Category</c> = <see cref="AuditFieldBuilders.BuildCategory"/>(channel) (= channel name).</item>
/// <item><c>Outcome</c> = <see cref="AuditOutcomeProjector.Project"/>(status, kind).</item>
/// <item><c>DetailsJson</c> carries Channel/Kind/Status (as strings) + every other
/// ScadaBridge domain field. <c>ForwardState</c> is NOT a DetailsJson field — it is
/// a site-storage-only concern handled by the site SQLite shim.</item>
/// </list>
/// </para>
/// </remarks>
public static class ScadaBridgeAuditEventFactory
{
/// <summary>
/// Builds the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for one ScadaBridge
/// audit row. <paramref name="channel"/>/<paramref name="kind"/>/<paramref name="status"/>
/// drive the canonical Action/Category/Outcome and are also recorded (as strings) in
/// <c>DetailsJson</c>; all remaining ScadaBridge domain fields are carried in
/// <c>DetailsJson</c> too.
/// </summary>
/// <param name="channel">Trust-boundary channel the audited action crossed.</param>
/// <param name="kind">Specific event kind within the channel.</param>
/// <param name="status">Lifecycle status of this row.</param>
/// <param name="eventId">Idempotency key. Defaults to a fresh <see cref="Guid"/> when omitted.</param>
/// <param name="occurredAtUtc">When the action occurred (UTC). Defaults to <see cref="DateTime.UtcNow"/> when omitted.</param>
/// <param name="actor">Authenticated actor for inbound paths (API key name, user, "system", etc.).</param>
/// <param name="target">Target of the action (external system, db connection, list name, inbound method).</param>
/// <param name="sourceNode">Cluster node that emitted the event (top-level canonical field).</param>
/// <param name="correlationId">Per-operation lifecycle correlation id (top-level canonical field).</param>
/// <param name="executionId">Originating script-execution / inbound-request id (DetailsJson).</param>
/// <param name="parentExecutionId">Spawning execution's id (DetailsJson).</param>
/// <param name="sourceSiteId">Site id where the action originated (DetailsJson).</param>
/// <param name="sourceInstanceId">Instance id where the action originated (DetailsJson).</param>
/// <param name="sourceScript">Script that initiated the action (DetailsJson).</param>
/// <param name="httpStatus">HTTP status code where applicable (DetailsJson).</param>
/// <param name="durationMs">Duration of the audited action in ms (DetailsJson).</param>
/// <param name="errorMessage">Human-readable error summary on failure rows (DetailsJson).</param>
/// <param name="errorDetail">Verbose error detail (stack/exception) on failure rows (DetailsJson).</param>
/// <param name="requestSummary">Truncated/redacted request summary (DetailsJson).</param>
/// <param name="responseSummary">Truncated/redacted response summary (DetailsJson).</param>
/// <param name="payloadTruncated">True when summaries were truncated to the payload cap (DetailsJson).</param>
/// <param name="extra">Free-form JSON extension for channel-specific extras (DetailsJson).</param>
/// <param name="ingestedAtUtc">UTC ingest timestamp (central-set; DetailsJson).</param>
public static AuditEvent Create(
AuditChannel channel,
AuditKind kind,
AuditStatus status,
Guid? eventId = null,
DateTime? occurredAtUtc = null,
string? actor = null,
string? target = null,
string? sourceNode = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceSiteId = null,
string? sourceInstanceId = null,
string? sourceScript = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
string? errorDetail = null,
string? requestSummary = null,
string? responseSummary = null,
bool payloadTruncated = false,
string? extra = null,
DateTimeOffset? ingestedAtUtc = null)
{
var details = new AuditDetails
{
Channel = channel.ToString(),
Kind = kind.ToString(),
Status = status.ToString(),
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = sourceSiteId,
SourceInstanceId = sourceInstanceId,
SourceScript = sourceScript,
HttpStatus = httpStatus,
DurationMs = durationMs,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
PayloadTruncated = payloadTruncated,
Extra = extra,
IngestedAtUtc = ingestedAtUtc,
};
return new AuditEvent
{
EventId = eventId ?? Guid.NewGuid(),
// DateTimeOffset assumes UTC when the source DateTime is Unspecified/Utc;
// every ScadaBridge OccurredAt value is UTC by contract.
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(occurredAtUtc ?? DateTime.UtcNow, DateTimeKind.Utc)),
Actor = actor ?? string.Empty,
Action = AuditFieldBuilders.BuildAction(channel, kind),
Category = AuditFieldBuilders.BuildCategory(channel),
Outcome = AuditOutcomeProjector.Project(status, kind),
Target = target,
SourceNode = sourceNode,
CorrelationId = correlationId,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
}