feat(audit): ScadaBridge C1 — AuditDetails codec (deterministic) + AuditOutcome projection + canonical field builders + ZB.MOM.WW.Audit ref (Task 2.5)

Additive foundation only — no existing type/interface/emitter changed.
Commons now references ZB.MOM.WW.Audit 0.1.0 (Gitea feed, central PM pin).
Adds four pure new types in Commons/Types/Audit/:
  AuditDetails (sealed record, 17 domain fields, declaration-order = JSON key order)
  AuditDetailsCodec (static; single cached JsonSerializerOptions: camelCase, no-indent,
    WhenWritingNull, UnsafeRelaxedJsonEscaping — byte-deterministic across calls)
  AuditOutcomeProjector (static; InboundAuthFailure→Denied first, then Delivered→Success,
    Failed/Parked/Discarded→Failure, all others→Success)
  AuditFieldBuilders (static; BuildAction="{channel}.{kind}", BuildCategory=channel.ToString())
56 new tests in Commons.Tests/Types/Audit/ covering codec round-trip, byte-determinism
(hand-pinned expected JSON string), null/empty sentinel, full projection table,
InboundAuthFailure-Denied precedence, and Action/Category builders. All pass.
This commit is contained in:
Joseph Doherty
2026-06-02 10:42:51 -04:00
parent 4118452e72
commit 3d77dc003c
8 changed files with 566 additions and 0 deletions
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// ScadaBridge domain fields that travel inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
/// All optional. <c>Channel</c>, <c>Kind</c>, and <c>Status</c> carry the string
/// representations of the corresponding ScadaBridge enums for stable JSON serialization.
/// <c>ForwardState</c> is deliberately absent — it remains a site-sidecar concern.
/// Serialized and deserialized by <see cref="AuditDetailsCodec"/>.
/// Property declaration order is load-bearing: <see cref="AuditDetailsCodec"/> relies on it for
/// byte-deterministic output (System.Text.Json respects declared order).
/// </summary>
/// <remarks>
/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only.
/// </remarks>
public sealed record AuditDetails
{
/// <summary>Top-level audit channel (<c>AuditChannel.ToString()</c>).</summary>
public string? Channel { get; init; }
/// <summary>Specific event kind (<c>AuditKind.ToString()</c>).</summary>
public string? Kind { get; init; }
/// <summary>Lifecycle status (<c>AuditStatus.ToString()</c>).</summary>
public string? Status { get; init; }
/// <summary>Execution / operation identifier for the audited call.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>Parent execution identifier for nested / cached operations.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site identifier where the event originated.</summary>
public string? SourceSiteId { get; init; }
/// <summary>Instance (Galaxy instance / target resource) identifier.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script name or endpoint that initiated the audited action.</summary>
public string? SourceScript { get; init; }
/// <summary>HTTP status code returned by the outbound or inbound call.</summary>
public int? HttpStatus { get; init; }
/// <summary>Elapsed duration of the audited action in milliseconds.</summary>
public int? DurationMs { get; init; }
/// <summary>Short error message when the action failed.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Full error detail / stack excerpt when the action failed.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Condensed request representation (URL, method, or payload summary).</summary>
public string? RequestSummary { get; init; }
/// <summary>Condensed response representation (status + body summary).</summary>
public string? ResponseSummary { get; init; }
/// <summary>
/// <c>true</c> if any string field in this record was truncated before serialization.
/// Included in JSON even when <c>false</c> (non-nullable bool, never null-omitted).
/// </summary>
public bool PayloadTruncated { get; init; }
/// <summary>Freeform extension JSON for fields not covered above.</summary>
public string? Extra { get; init; }
/// <summary>UTC instant when the central AuditLog store ingested this event.</summary>
public DateTimeOffset? IngestedAtUtc { get; init; }
}
@@ -0,0 +1,72 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Serializes and deserializes <see cref="AuditDetails"/> to/from the JSON string that
/// occupies <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
/// </summary>
/// <remarks>
/// <para>
/// The options are DETERMINISTIC by design — this is a load-bearing contract:
/// canonical <c>AuditEvent</c> uses value-equality (record) and consumers dedup on it, so
/// two independent emitters must produce byte-identical <c>DetailsJson</c> for equal inputs.
/// The options that make this deterministic:
/// <list type="bullet">
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — stable camelCase key names.</item>
/// <item><c>WriteIndented = false</c> — no whitespace variation.</item>
/// <item><see cref="JsonIgnoreCondition.WhenWritingNull"/> — null fields absent; non-nullable
/// <c>bool</c> fields (like <see cref="AuditDetails.PayloadTruncated"/>) always present.</item>
/// <item><see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> — angle brackets and other
/// extended characters (including <c>&lt;redacted&gt;</c> markers) are NOT percent-escaped,
/// preserving the exact byte value of any existing redaction strings.</item>
/// <item>Property declaration order on <see cref="AuditDetails"/> fixes key order —
/// System.Text.Json honours declared order, so serialization is stable across calls.</item>
/// </list>
/// </para>
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
/// </remarks>
public static class AuditDetailsCodec
{
/// <summary>
/// Single, cached, immutable options instance. Re-using one instance avoids repeated
/// reflection overhead and guarantees the same encoder is used across all serialization calls.
/// </summary>
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// Serializes <paramref name="details"/> to a compact, deterministic JSON string
/// suitable for storage in <c>AuditEvent.DetailsJson</c>.
/// </summary>
public static string Serialize(AuditDetails details)
=> JsonSerializer.Serialize(details, Options);
/// <summary>
/// Deserializes a <see cref="AuditDetails"/> from <paramref name="json"/>.
/// Returns an empty (all-null) <see cref="AuditDetails"/> when <paramref name="json"/>
/// is <c>null</c>, empty, or whitespace — never throws.
/// </summary>
public static AuditDetails Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new AuditDetails();
try
{
return JsonSerializer.Deserialize<AuditDetails>(json, Options)
?? new AuditDetails();
}
catch (JsonException)
{
return new AuditDetails();
}
}
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Builds the canonical <c>Action</c> and <c>Category</c> fields for
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> from ScadaBridge's channel + kind enums.
/// </summary>
/// <remarks>
/// <para>
/// <list type="bullet">
/// <item><see cref="BuildAction"/> produces <c>"{channel}.{kind}"</c> — a stable dot-separated
/// identifier that maps directly onto the canonical <c>Action</c> field.</item>
/// <item><see cref="BuildCategory"/> produces <c>channel.ToString()</c> — a grouping token
/// that maps onto the canonical <c>Category</c> field.</item>
/// </list>
/// </para>
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
/// </remarks>
public static class AuditFieldBuilders
{
/// <summary>
/// Returns the canonical <c>Action</c> string: <c>"{channel}.{kind}"</c>.
/// </summary>
public static string BuildAction(AuditChannel channel, AuditKind kind)
=> $"{channel}.{kind}";
/// <summary>
/// Returns the canonical <c>Category</c> string: the channel name.
/// </summary>
public static string BuildCategory(AuditChannel channel)
=> channel.ToString();
}
@@ -0,0 +1,45 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Maps the ScadaBridge (<see cref="AuditStatus"/>, <see cref="AuditKind"/>) pair onto
/// the canonical <see cref="AuditOutcome"/> required by <c>ZB.MOM.WW.Audit.AuditEvent</c>.
/// </summary>
/// <remarks>
/// <para>Projection table:</para>
/// <list type="bullet">
/// <item><see cref="AuditKind.InboundAuthFailure"/> → <see cref="AuditOutcome.Denied"/>
/// (checked <b>first</b>, overrides any status).</item>
/// <item><see cref="AuditStatus.Delivered"/> → <see cref="AuditOutcome.Success"/>.</item>
/// <item><see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// <see cref="AuditStatus.Discarded"/> → <see cref="AuditOutcome.Failure"/>.</item>
/// <item>All other statuses (<c>Submitted</c>, <c>Forwarded</c>, <c>Attempted</c>,
/// <c>Skipped</c>) → <see cref="AuditOutcome.Success"/>.</item>
/// </list>
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
/// </remarks>
public static class AuditOutcomeProjector
{
/// <summary>
/// Projects <paramref name="status"/> + <paramref name="kind"/> onto the canonical
/// <see cref="AuditOutcome"/>.
/// </summary>
public static AuditOutcome Project(AuditStatus status, AuditKind kind)
{
// Auth-failure kind takes absolute precedence — checked before any status rule.
if (kind == AuditKind.InboundAuthFailure)
return AuditOutcome.Denied;
return status switch
{
AuditStatus.Delivered => AuditOutcome.Success,
AuditStatus.Failed => AuditOutcome.Failure,
AuditStatus.Parked => AuditOutcome.Failure,
AuditStatus.Discarded => AuditOutcome.Failure,
// Submitted / Forwarded / Attempted / Skipped → in-progress or short-circuit → Success
_ => AuditOutcome.Success,
};
}
}
@@ -7,4 +7,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
</Project>