diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs
new file mode 100644
index 00000000..0d703cf3
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetails.cs
@@ -0,0 +1,70 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+///
+/// ScadaBridge domain fields that travel inside ZB.MOM.WW.Audit.AuditEvent.DetailsJson.
+/// All optional. Channel, Kind, and Status carry the string
+/// representations of the corresponding ScadaBridge enums for stable JSON serialization.
+/// ForwardState is deliberately absent — it remains a site-sidecar concern.
+/// Serialized and deserialized by .
+/// Property declaration order is load-bearing: relies on it for
+/// byte-deterministic output (System.Text.Json respects declared order).
+///
+///
+/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only.
+///
+public sealed record AuditDetails
+{
+ /// Top-level audit channel (AuditChannel.ToString()).
+ public string? Channel { get; init; }
+
+ /// Specific event kind (AuditKind.ToString()).
+ public string? Kind { get; init; }
+
+ /// Lifecycle status (AuditStatus.ToString()).
+ public string? Status { get; init; }
+
+ /// Execution / operation identifier for the audited call.
+ public Guid? ExecutionId { get; init; }
+
+ /// Parent execution identifier for nested / cached operations.
+ public Guid? ParentExecutionId { get; init; }
+
+ /// Site identifier where the event originated.
+ public string? SourceSiteId { get; init; }
+
+ /// Instance (Galaxy instance / target resource) identifier.
+ public string? SourceInstanceId { get; init; }
+
+ /// Script name or endpoint that initiated the audited action.
+ public string? SourceScript { get; init; }
+
+ /// HTTP status code returned by the outbound or inbound call.
+ public int? HttpStatus { get; init; }
+
+ /// Elapsed duration of the audited action in milliseconds.
+ public int? DurationMs { get; init; }
+
+ /// Short error message when the action failed.
+ public string? ErrorMessage { get; init; }
+
+ /// Full error detail / stack excerpt when the action failed.
+ public string? ErrorDetail { get; init; }
+
+ /// Condensed request representation (URL, method, or payload summary).
+ public string? RequestSummary { get; init; }
+
+ /// Condensed response representation (status + body summary).
+ public string? ResponseSummary { get; init; }
+
+ ///
+ /// true if any string field in this record was truncated before serialization.
+ /// Included in JSON even when false (non-nullable bool, never null-omitted).
+ ///
+ public bool PayloadTruncated { get; init; }
+
+ /// Freeform extension JSON for fields not covered above.
+ public string? Extra { get; init; }
+
+ /// UTC instant when the central AuditLog store ingested this event.
+ public DateTimeOffset? IngestedAtUtc { get; init; }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs
new file mode 100644
index 00000000..a55a5458
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditDetailsCodec.cs
@@ -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;
+
+///
+/// Serializes and deserializes to/from the JSON string that
+/// occupies ZB.MOM.WW.Audit.AuditEvent.DetailsJson.
+///
+///
+///
+/// The options are DETERMINISTIC by design — this is a load-bearing contract:
+/// canonical AuditEvent uses value-equality (record) and consumers dedup on it, so
+/// two independent emitters must produce byte-identical DetailsJson for equal inputs.
+/// The options that make this deterministic:
+///
+/// - — stable camelCase key names.
+/// - WriteIndented = false — no whitespace variation.
+/// - — null fields absent; non-nullable
+/// bool fields (like ) always present.
+/// - — angle brackets and other
+/// extended characters (including <redacted> markers) are NOT percent-escaped,
+/// preserving the exact byte value of any existing redaction strings.
+/// - Property declaration order on fixes key order —
+/// System.Text.Json honours declared order, so serialization is stable across calls.
+///
+///
+/// C1 of the ScadaBridge audit re-architecture (Task 2.5).
+///
+public static class AuditDetailsCodec
+{
+ ///
+ /// Single, cached, immutable options instance. Re-using one instance avoids repeated
+ /// reflection overhead and guarantees the same encoder is used across all serialization calls.
+ ///
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ ///
+ /// Serializes to a compact, deterministic JSON string
+ /// suitable for storage in AuditEvent.DetailsJson.
+ ///
+ public static string Serialize(AuditDetails details)
+ => JsonSerializer.Serialize(details, Options);
+
+ ///
+ /// Deserializes a from .
+ /// Returns an empty (all-null) when
+ /// is null, empty, or whitespace — never throws.
+ ///
+ public static AuditDetails Deserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return new AuditDetails();
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, Options)
+ ?? new AuditDetails();
+ }
+ catch (JsonException)
+ {
+ return new AuditDetails();
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs
new file mode 100644
index 00000000..b6c6bc57
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditFieldBuilders.cs
@@ -0,0 +1,33 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+///
+/// Builds the canonical Action and Category fields for
+/// ZB.MOM.WW.Audit.AuditEvent from ScadaBridge's channel + kind enums.
+///
+///
+///
+///
+/// - produces "{channel}.{kind}" — a stable dot-separated
+/// identifier that maps directly onto the canonical Action field.
+/// - produces channel.ToString() — a grouping token
+/// that maps onto the canonical Category field.
+///
+///
+/// C1 of the ScadaBridge audit re-architecture (Task 2.5).
+///
+public static class AuditFieldBuilders
+{
+ ///
+ /// Returns the canonical Action string: "{channel}.{kind}".
+ ///
+ public static string BuildAction(AuditChannel channel, AuditKind kind)
+ => $"{channel}.{kind}";
+
+ ///
+ /// Returns the canonical Category string: the channel name.
+ ///
+ public static string BuildCategory(AuditChannel channel)
+ => channel.ToString();
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs
new file mode 100644
index 00000000..4bbf193e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditOutcomeProjector.cs
@@ -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;
+
+///
+/// Maps the ScadaBridge (, ) pair onto
+/// the canonical required by ZB.MOM.WW.Audit.AuditEvent.
+///
+///
+/// Projection table:
+///
+/// - →
+/// (checked first, overrides any status).
+/// - → .
+/// - , ,
+/// → .
+/// - All other statuses (Submitted, Forwarded, Attempted,
+/// Skipped) → .
+///
+/// C1 of the ScadaBridge audit re-architecture (Task 2.5).
+///
+public static class AuditOutcomeProjector
+{
+ ///
+ /// Projects + onto the canonical
+ /// .
+ ///
+ 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,
+ };
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj b/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj
index 023297e2..55b3ee87 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj
@@ -7,4 +7,8 @@
true
+
+
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs
new file mode 100644
index 00000000..b059bdc4
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditDetailsCodecTests.cs
@@ -0,0 +1,188 @@
+using System.Text;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
+
+///
+/// C1 codec tests: round-trip, byte-determinism, and null/empty sentinel.
+/// Determinism is load-bearing — consumers dedup on canonical AuditEvent value-equality
+/// which includes DetailsJson, so two emitters must produce identical bytes for equal inputs.
+///
+public class AuditDetailsCodecTests
+{
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static AuditDetails FullDetails() => new()
+ {
+ Channel = AuditChannel.ApiOutbound.ToString(),
+ Kind = AuditKind.ApiCall.ToString(),
+ Status = AuditStatus.Delivered.ToString(),
+ ExecutionId = new Guid("11111111-1111-1111-1111-111111111111"),
+ ParentExecutionId = new Guid("22222222-2222-2222-2222-222222222222"),
+ SourceSiteId = "site-a",
+ SourceInstanceId = "inst-001",
+ SourceScript = "MyScript",
+ HttpStatus = 200,
+ DurationMs = 42,
+ ErrorMessage = null,
+ ErrorDetail = null,
+ RequestSummary = "GET /api/data",
+ ResponseSummary = "{\"ok\":true}",
+ PayloadTruncated = false,
+ Extra = null,
+ IngestedAtUtc = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero),
+ };
+
+ // ---------------------------------------------------------------------------
+ // Round-trip: all non-null fields survive
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void RoundTrip_AllFields_Populated()
+ {
+ var original = FullDetails();
+ var json = AuditDetailsCodec.Serialize(original);
+ var restored = AuditDetailsCodec.Deserialize(json);
+
+ Assert.Equal(original.Channel, restored.Channel);
+ Assert.Equal(original.Kind, restored.Kind);
+ Assert.Equal(original.Status, restored.Status);
+ Assert.Equal(original.ExecutionId, restored.ExecutionId);
+ Assert.Equal(original.ParentExecutionId, restored.ParentExecutionId);
+ Assert.Equal(original.SourceSiteId, restored.SourceSiteId);
+ Assert.Equal(original.SourceInstanceId, restored.SourceInstanceId);
+ Assert.Equal(original.SourceScript, restored.SourceScript);
+ Assert.Equal(original.HttpStatus, restored.HttpStatus);
+ Assert.Equal(original.DurationMs, restored.DurationMs);
+ Assert.Equal(original.RequestSummary, restored.RequestSummary);
+ Assert.Equal(original.ResponseSummary, restored.ResponseSummary);
+ Assert.Equal(original.PayloadTruncated, restored.PayloadTruncated);
+ Assert.Equal(original.IngestedAtUtc, restored.IngestedAtUtc);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Round-trip: all-null record
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void RoundTrip_AllNullOptionals_ReturnsEmptyDetails()
+ {
+ var empty = new AuditDetails();
+ var json = AuditDetailsCodec.Serialize(empty);
+ var restored = AuditDetailsCodec.Deserialize(json);
+
+ Assert.Null(restored.Channel);
+ Assert.Null(restored.Kind);
+ Assert.Null(restored.Status);
+ Assert.Null(restored.ExecutionId);
+ Assert.Null(restored.ParentExecutionId);
+ Assert.Null(restored.SourceSiteId);
+ Assert.Null(restored.SourceInstanceId);
+ Assert.Null(restored.SourceScript);
+ Assert.Null(restored.HttpStatus);
+ Assert.Null(restored.DurationMs);
+ Assert.Null(restored.ErrorMessage);
+ Assert.Null(restored.ErrorDetail);
+ Assert.Null(restored.RequestSummary);
+ Assert.Null(restored.ResponseSummary);
+ Assert.False(restored.PayloadTruncated);
+ Assert.Null(restored.Extra);
+ Assert.Null(restored.IngestedAtUtc);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Null / empty JSON deserialize → empty AuditDetails (no throw)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void Deserialize_Null_ReturnsEmpty()
+ {
+ var d = AuditDetailsCodec.Deserialize(null);
+ Assert.NotNull(d);
+ Assert.Null(d.Channel);
+ }
+
+ [Fact]
+ public void Deserialize_EmptyString_ReturnsEmpty()
+ {
+ var d = AuditDetailsCodec.Deserialize(string.Empty);
+ Assert.NotNull(d);
+ Assert.Null(d.Channel);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Determinism: same input → byte-identical JSON across two calls
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void Serialize_SameInput_ProducesByteIdenticalOutput_AcrossCalls()
+ {
+ var details = FullDetails();
+ var json1 = AuditDetailsCodec.Serialize(details);
+ var json2 = AuditDetailsCodec.Serialize(details);
+
+ Assert.Equal(json1, json2);
+ Assert.Equal(
+ Encoding.UTF8.GetBytes(json1),
+ Encoding.UTF8.GetBytes(json2));
+ }
+
+ // ---------------------------------------------------------------------------
+ // Determinism: exact expected JSON string (camelCase key order = declaration order,
+ // null fields omitted, PayloadTruncated=false present because it is non-nullable bool)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void Serialize_KnownInput_MatchesExpectedJson()
+ {
+ // Build a minimal deterministic fixture (only non-null fields we can pin exactly).
+ var details = new AuditDetails
+ {
+ Channel = "ApiOutbound",
+ Kind = "ApiCall",
+ Status = "Delivered",
+ ExecutionId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
+ SourceSiteId = "site-x",
+ HttpStatus = 200,
+ DurationMs = 10,
+ RequestSummary = "GET /ping",
+ ResponseSummary = "ok",
+ PayloadTruncated = false,
+ IngestedAtUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
+ };
+
+ // Expected: camelCase, no indent, nulls omitted, PayloadTruncated present (false is default for bool),
+ // key order follows property declaration order on AuditDetails.
+ // ingestedAtUtc serializes as ISO-8601 with offset.
+ const string expected =
+ "{\"channel\":\"ApiOutbound\"," +
+ "\"kind\":\"ApiCall\"," +
+ "\"status\":\"Delivered\"," +
+ "\"executionId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"," +
+ "\"sourceSiteId\":\"site-x\"," +
+ "\"httpStatus\":200," +
+ "\"durationMs\":10," +
+ "\"requestSummary\":\"GET /ping\"," +
+ "\"responseSummary\":\"ok\"," +
+ "\"payloadTruncated\":false," +
+ "\"ingestedAtUtc\":\"2026-01-02T03:04:05+00:00\"}";
+
+ var actual = AuditDetailsCodec.Serialize(details);
+ Assert.Equal(expected, actual);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Redaction marker preserved verbatim (UnsafeRelaxedJsonEscaping must not mangle <>)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void Serialize_RedactionMarker_IsPreservedVerbatim()
+ {
+ var details = new AuditDetails { RequestSummary = "" };
+ var json = AuditDetailsCodec.Serialize(details);
+ Assert.Contains("\"\"", json);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs
new file mode 100644
index 00000000..90487b0c
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditFieldBuildersTests.cs
@@ -0,0 +1,44 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
+
+///
+/// C1 canonical field builder tests: BuildAction and BuildCategory.
+///
+public class AuditFieldBuildersTests
+{
+ // ---------------------------------------------------------------------------
+ // BuildAction: "{channel}.{kind}"
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCall, "ApiOutbound.ApiCall")]
+ [InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, "ApiOutbound.ApiCallCached")]
+ [InlineData(AuditChannel.DbOutbound, AuditKind.DbWrite, "DbOutbound.DbWrite")]
+ [InlineData(AuditChannel.DbOutbound, AuditKind.DbWriteCached, "DbOutbound.DbWriteCached")]
+ [InlineData(AuditChannel.Notification, AuditKind.NotifySend, "Notification.NotifySend")]
+ [InlineData(AuditChannel.Notification, AuditKind.NotifyDeliver, "Notification.NotifyDeliver")]
+ [InlineData(AuditChannel.ApiInbound, AuditKind.InboundRequest, "ApiInbound.InboundRequest")]
+ [InlineData(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure, "ApiInbound.InboundAuthFailure")]
+ [InlineData(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, "ApiOutbound.CachedSubmit")]
+ [InlineData(AuditChannel.ApiOutbound, AuditKind.CachedResolve, "ApiOutbound.CachedResolve")]
+ public void BuildAction_ReturnsChannelDotKind(AuditChannel channel, AuditKind kind, string expected)
+ {
+ Assert.Equal(expected, AuditFieldBuilders.BuildAction(channel, kind));
+ }
+
+ // ---------------------------------------------------------------------------
+ // BuildCategory: channel.ToString()
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditChannel.ApiOutbound, "ApiOutbound")]
+ [InlineData(AuditChannel.DbOutbound, "DbOutbound")]
+ [InlineData(AuditChannel.Notification, "Notification")]
+ [InlineData(AuditChannel.ApiInbound, "ApiInbound")]
+ public void BuildCategory_ReturnsChannelName(AuditChannel channel, string expected)
+ {
+ Assert.Equal(expected, AuditFieldBuilders.BuildCategory(channel));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs
new file mode 100644
index 00000000..66666cd7
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Audit/AuditOutcomeProjectorTests.cs
@@ -0,0 +1,110 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
+
+///
+/// C1 projection tests: the full AuditStatus × AuditKind → AuditOutcome table, plus
+/// the InboundAuthFailure-Denied precedence that must be checked before any status rule.
+///
+public class AuditOutcomeProjectorTests
+{
+ // ---------------------------------------------------------------------------
+ // InboundAuthFailure → Denied, regardless of status (checked FIRST)
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditStatus.Submitted)]
+ [InlineData(AuditStatus.Forwarded)]
+ [InlineData(AuditStatus.Attempted)]
+ [InlineData(AuditStatus.Delivered)]
+ [InlineData(AuditStatus.Failed)]
+ [InlineData(AuditStatus.Parked)]
+ [InlineData(AuditStatus.Discarded)]
+ [InlineData(AuditStatus.Skipped)]
+ public void InboundAuthFailure_AlwaysDenied_RegardlessOfStatus(AuditStatus status)
+ {
+ var outcome = AuditOutcomeProjector.Project(status, AuditKind.InboundAuthFailure);
+ Assert.Equal(AuditOutcome.Denied, outcome);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Delivered → Success (for all non-auth-failure kinds)
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditKind.ApiCall)]
+ [InlineData(AuditKind.ApiCallCached)]
+ [InlineData(AuditKind.DbWrite)]
+ [InlineData(AuditKind.DbWriteCached)]
+ [InlineData(AuditKind.NotifySend)]
+ [InlineData(AuditKind.NotifyDeliver)]
+ [InlineData(AuditKind.InboundRequest)]
+ [InlineData(AuditKind.CachedSubmit)]
+ [InlineData(AuditKind.CachedResolve)]
+ public void Delivered_Success_ForNonAuthFailureKinds(AuditKind kind)
+ {
+ var outcome = AuditOutcomeProjector.Project(AuditStatus.Delivered, kind);
+ Assert.Equal(AuditOutcome.Success, outcome);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Failure statuses → Failure outcome
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditStatus.Failed)]
+ [InlineData(AuditStatus.Parked)]
+ [InlineData(AuditStatus.Discarded)]
+ public void FailureStatuses_MapToFailureOutcome(AuditStatus status)
+ {
+ // Use a representative non-auth-failure kind
+ var outcome = AuditOutcomeProjector.Project(status, AuditKind.ApiCall);
+ Assert.Equal(AuditOutcome.Failure, outcome);
+ }
+
+ // InboundAuthFailure still wins over failure statuses
+ [Theory]
+ [InlineData(AuditStatus.Failed)]
+ [InlineData(AuditStatus.Parked)]
+ [InlineData(AuditStatus.Discarded)]
+ public void InboundAuthFailure_OverridesFailureStatuses(AuditStatus status)
+ {
+ var outcome = AuditOutcomeProjector.Project(status, AuditKind.InboundAuthFailure);
+ Assert.Equal(AuditOutcome.Denied, outcome);
+ }
+
+ // ---------------------------------------------------------------------------
+ // In-progress / short-circuit statuses → Success
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditStatus.Submitted)]
+ [InlineData(AuditStatus.Forwarded)]
+ [InlineData(AuditStatus.Attempted)]
+ [InlineData(AuditStatus.Skipped)]
+ public void InProgressStatuses_MapToSuccess(AuditStatus status)
+ {
+ var outcome = AuditOutcomeProjector.Project(status, AuditKind.ApiCall);
+ Assert.Equal(AuditOutcome.Success, outcome);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Full status × non-auth-failure kind cross-product (spot-check each status once more)
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(AuditStatus.Submitted, AuditKind.DbWrite, AuditOutcome.Success)]
+ [InlineData(AuditStatus.Forwarded, AuditKind.CachedSubmit, AuditOutcome.Success)]
+ [InlineData(AuditStatus.Attempted, AuditKind.NotifySend, AuditOutcome.Success)]
+ [InlineData(AuditStatus.Delivered, AuditKind.NotifyDeliver, AuditOutcome.Success)]
+ [InlineData(AuditStatus.Failed, AuditKind.DbWriteCached, AuditOutcome.Failure)]
+ [InlineData(AuditStatus.Parked, AuditKind.NotifySend, AuditOutcome.Failure)]
+ [InlineData(AuditStatus.Discarded, AuditKind.CachedResolve, AuditOutcome.Failure)]
+ [InlineData(AuditStatus.Skipped, AuditKind.InboundRequest, AuditOutcome.Success)]
+ public void ProjectionTable_MatchesSpec(AuditStatus status, AuditKind kind, AuditOutcome expected)
+ {
+ Assert.Equal(expected, AuditOutcomeProjector.Project(status, kind));
+ }
+}