diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs
new file mode 100644
index 00000000..d4e710b8
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs
@@ -0,0 +1,401 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Coverage for and the six DecisionEngine emission
+ /// sites that previously published terminal / state-changing timeline events with empty
+ /// or near-empty EnrollmentEvent.Data: whiteglove_complete,
+ /// whiteglove_resumed, whiteglove_part2_complete, enrollment_failed
+ /// (Part-2 safety / EspTerminalFailure / EffectInfrastructureFailure). Each test exercises
+ /// the reducer's typed-payload contract end-to-end so a regression to "empty Data on the
+ /// wire" is caught at the engine boundary instead of in a UI smoke test.
+ ///
+ public sealed class DecisionAuditTrailTests
+ {
+ private static readonly DateTime T0 = new DateTime(2026, 4, 30, 8, 0, 0, DateTimeKind.Utc);
+
+ private static DecisionSignal MakeSignal(
+ long ordinal,
+ DecisionSignalKind kind,
+ DateTime occurredAtUtc,
+ IReadOnlyDictionary? payload = null,
+ string sourceOrigin = "test")
+ {
+ return new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: 1,
+ occurredAtUtc: occurredAtUtc,
+ sourceOrigin: sourceOrigin,
+ evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
+ payload: payload);
+ }
+
+ private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
+ step.Effects.Single(e =>
+ e.Kind == DecisionEffectKind.EmitEventTimelineEntry
+ && e.Parameters != null
+ && e.Parameters.TryGetValue("eventType", out var et)
+ && et == eventType);
+
+ // ============================================================ builder unit tests
+
+ [Fact]
+ public void Build_emits_mandatory_anchor_fields_for_minimal_state()
+ {
+ var state = DecisionState.CreateInitial("s", "t").ToBuilder()
+ .WithStepIndex(7)
+ .WithLastAppliedSignalOrdinal(42)
+ .Build();
+
+ var data = DecisionAuditTrailBuilder.Build(
+ postState: state,
+ decidedStage: SessionStage.SessionStarted,
+ trigger: "Test:Trigger");
+
+ Assert.Equal("DecisionEngine", data["decisionSource"]);
+ Assert.Equal("Test:Trigger", data["trigger"]);
+ Assert.Equal(nameof(SessionStage.SessionStarted), data["sessionStage"]);
+ Assert.Equal(7, data["stepIndex"]);
+ Assert.Equal(42L, data["signalOrdinal"]);
+ Assert.IsType>(data["signalsSeen"]);
+ Assert.IsType>(data["signalEvidence"]);
+ Assert.IsType>(data["signalTimestamps"]);
+ Assert.IsType>(data["scenario"]);
+ Assert.False(data.ContainsKey("classifier"));
+ Assert.False(data.ContainsKey("classifierInputs"));
+ Assert.False(data.ContainsKey("reason"));
+ }
+
+ [Fact]
+ public void Build_omits_signalOrdinal_when_state_has_no_applied_signal()
+ {
+ // CreateInitial leaves LastAppliedSignalOrdinal at -1 — the builder should NOT
+ // forge a "signalOrdinal=-1" entry that would mislead the Inspector.
+ var state = DecisionState.CreateInitial("s", "t");
+
+ var data = DecisionAuditTrailBuilder.Build(
+ postState: state,
+ decidedStage: SessionStage.SessionStarted,
+ trigger: "Test:NoSignal");
+
+ Assert.False(data.ContainsKey("signalOrdinal"));
+ }
+
+ [Fact]
+ public void Build_includes_failureReason_only_when_provided()
+ {
+ var state = DecisionState.CreateInitial("s", "t");
+
+ var withReason = DecisionAuditTrailBuilder.Build(
+ postState: state,
+ decidedStage: SessionStage.Failed,
+ trigger: "Test:Failure",
+ failureReason: "ime_pattern_failure");
+ Assert.Equal("ime_pattern_failure", withReason["reason"]);
+
+ var withoutReason = DecisionAuditTrailBuilder.Build(
+ postState: state,
+ decidedStage: SessionStage.Failed,
+ trigger: "Test:Failure");
+ Assert.False(withoutReason.ContainsKey("reason"));
+ }
+
+ [Fact]
+ public void Build_classifier_section_only_appears_when_verdict_supplied()
+ {
+ var state = DecisionState.CreateInitial("s", "t");
+ var verdict = new ClassifierVerdictInfo("WhiteGloveSealing", "Confirmed", 80, "shellcore_alone", "abc123");
+
+ var data = DecisionAuditTrailBuilder.Build(
+ postState: state,
+ decidedStage: SessionStage.WhiteGloveSealed,
+ trigger: "Test:Classifier",
+ classifier: verdict);
+
+ var classifierBlock = Assert.IsType>(data["classifier"]);
+ Assert.Equal("WhiteGloveSealing", classifierBlock["id"]);
+ Assert.Equal("Confirmed", classifierBlock["level"]);
+ Assert.Equal(80, classifierBlock["score"]);
+ Assert.Equal("shellcore_alone", classifierBlock["reason"]);
+ Assert.Equal("abc123", classifierBlock["inputHash"]);
+ }
+
+ [Fact]
+ public void Build_classifierInputs_flattens_snapshot_record_to_camelCased_dict()
+ {
+ var snapshot = new WhiteGloveSealingSnapshot(
+ shellCoreWhiteGloveSuccessSeen: true,
+ whiteGloveSealingPatternSeen: true,
+ aadJoinedWithUser: false,
+ desktopArrived: false,
+ helloResolved: false,
+ hasAccountSetupActivity: false,
+ isDeviceOnlyDeploymentHypothesis: false,
+ systemRebootUtc: null,
+ currentEnrollmentPhase: null);
+
+ var data = DecisionAuditTrailBuilder.Build(
+ postState: DecisionState.CreateInitial("s", "t"),
+ decidedStage: SessionStage.WhiteGloveSealed,
+ trigger: "Test:Inputs",
+ classifierInputs: snapshot);
+
+ var inputs = Assert.IsType>(data["classifierInputs"]);
+ Assert.Equal(true, inputs["shellCoreWhiteGloveSuccessSeen"]);
+ Assert.Equal(true, inputs["whiteGloveSealingPatternSeen"]);
+ Assert.Equal(false, inputs["aadJoinedWithUser"]);
+ // Null nullable properties are skipped, not emitted as null sentinels.
+ Assert.False(inputs.ContainsKey("systemRebootUtc"));
+ Assert.False(inputs.ContainsKey("currentEnrollmentPhase"));
+ }
+
+ [Fact]
+ public void VerdictFromSignalPayload_returns_null_for_missing_keys()
+ {
+ Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload(null));
+ Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload(new Dictionary()));
+ Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload(
+ new Dictionary { ["classifier"] = "X" })); // no level
+ }
+
+ [Fact]
+ public void VerdictFromSignalPayload_parses_full_payload()
+ {
+ var v = DecisionAuditTrailBuilder.VerdictFromSignalPayload(
+ new Dictionary
+ {
+ ["classifier"] = "WhiteGloveSealing",
+ ["level"] = "Confirmed",
+ ["score"] = "80",
+ ["reason"] = "shellcore_alone",
+ ["inputHash"] = "abc123",
+ });
+
+ Assert.NotNull(v);
+ Assert.Equal("WhiteGloveSealing", v!.Id);
+ Assert.Equal("Confirmed", v.Level);
+ Assert.Equal(80, v.Score);
+ Assert.Equal("shellcore_alone", v.Reason);
+ Assert.Equal("abc123", v.InputHash);
+ }
+
+ // ============================================================ whiteglove_complete
+
+ [Fact]
+ public void WhiteGloveComplete_carries_audit_trail_with_classifier_verdict_and_inputs()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("sess-wg", "tenant-wg");
+ // Drive through SessionStarted so the StepIndex / LastAppliedSignalOrdinal are real.
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ // Observe ShellCore success — the inline-WG fast path: a single high-confidence signal
+ // is enough for the classifier to seal at 80/100.
+ var shellSignal = MakeSignal(1, DecisionSignalKind.WhiteGloveShellCoreSuccess, T0.AddMinutes(5));
+ state = engine.Reduce(state, shellSignal).NewState;
+
+ // The reducer emitted a RunClassifier effect; the harness here synthesizes the
+ // resulting verdict signal directly so the WG verdict-applier path runs.
+ var verdictSignal = MakeSignal(2, DecisionSignalKind.ClassifierVerdictIssued, T0.AddMinutes(5),
+ new Dictionary
+ {
+ ["classifier"] = WhiteGloveSealingClassifier.ClassifierId,
+ ["level"] = "Confirmed",
+ ["score"] = "80",
+ ["reason"] = "shellcore_alone",
+ ["inputHash"] = "deadbeef",
+ });
+
+ var step = engine.Reduce(state, verdictSignal);
+ Assert.Equal(SessionStage.WhiteGloveSealed, step.NewState.Stage);
+
+ var effect = SingleTimelineEffect(step, "whiteglove_complete");
+ var payload = Assert.IsType>(effect.TypedPayload);
+
+ Assert.Equal("DecisionEngine", payload["decisionSource"]);
+ Assert.Equal($"ClassifierVerdictIssued:{WhiteGloveSealingClassifier.ClassifierId}:Confirmed", payload["trigger"]);
+ Assert.Equal(nameof(SessionStage.WhiteGloveSealed), payload["sessionStage"]);
+
+ var signalsSeen = Assert.IsType>(payload["signalsSeen"]);
+ Assert.Contains("shellcore_whiteglove_success", signalsSeen);
+
+ var classifier = Assert.IsType>(payload["classifier"]);
+ Assert.Equal(WhiteGloveSealingClassifier.ClassifierId, classifier["id"]);
+ Assert.Equal("Confirmed", classifier["level"]);
+ Assert.Equal(80, classifier["score"]);
+ Assert.Equal("deadbeef", classifier["inputHash"]);
+
+ var inputs = Assert.IsType>(payload["classifierInputs"]);
+ Assert.Equal(true, inputs["shellCoreWhiteGloveSuccessSeen"]);
+ Assert.Equal(false, inputs["aadJoinedWithUser"]);
+
+ var scenario = Assert.IsType>(payload["scenario"]);
+ Assert.Equal(EnrollmentMode.WhiteGlove.ToString(), scenario["mode"]);
+ }
+
+ // ============================================================ whiteglove_resumed
+
+ [Fact]
+ public void WhiteGloveResumed_attaches_audit_trail_alongside_legacy_scalar_parameters()
+ {
+ var engine = new DecisionEngine();
+ // Sealed Part-1 state recovered after reboot.
+ var sealedState = DecisionState.CreateInitial("sess-wgr", "tenant-wgr")
+ .ToBuilder()
+ .WithStage(SessionStage.WhiteGloveSealed)
+ .Build();
+ var resumeUtc = new DateTime(2026, 4, 30, 9, 0, 0, DateTimeKind.Utc);
+ var step = engine.Reduce(sealedState, MakeSignal(
+ 10, DecisionSignalKind.SessionRecovered, resumeUtc, sourceOrigin: "EnrollmentOrchestrator"));
+
+ var effect = SingleTimelineEffect(step, "whiteglove_resumed");
+
+ // Legacy scalar parameters preserved (existing assertions in ReducerTimelineEffectTests).
+ Assert.Equal(resumeUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
+ effect.Parameters!["resumedAtUtc"]);
+ Assert.Equal("EnrollmentOrchestrator", effect.Parameters["sourceOrigin"]);
+
+ // Newly-attached audit trail.
+ var payload = Assert.IsType>(effect.TypedPayload);
+ Assert.Equal("SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn", payload["trigger"]);
+ Assert.Equal(nameof(SessionStage.WhiteGloveAwaitingUserSignIn), payload["sessionStage"]);
+ // The bridge-specific scalars are present in the typed payload too.
+ Assert.Equal(resumeUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), payload["resumedAtUtc"]);
+ Assert.Equal("EnrollmentOrchestrator", payload["sourceOrigin"]);
+ }
+
+ // ============================================================ whiteglove_part2_complete
+
+ [Fact]
+ public void WhiteGlovePart2Complete_carries_audit_trail_with_part2_verdict_and_facts()
+ {
+ var engine = new DecisionEngine();
+ // Establish Part-2 awaiting state with all four Part-2 facts in place so the
+ // verdict applier transitions to WhiteGloveCompletedPart2.
+ var state = DecisionState.CreateInitial("sess-wg2", "tenant-wg2")
+ .ToBuilder()
+ .WithStage(SessionStage.WhiteGloveAwaitingUserSignIn)
+ .Build();
+ state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.UserAadSignInComplete, T0.AddHours(2))).NewState;
+ state = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.HelloResolvedPart2, T0.AddHours(2).AddSeconds(10))).NewState;
+ state = engine.Reduce(state, MakeSignal(22, DecisionSignalKind.DesktopArrivedPart2, T0.AddHours(2).AddSeconds(20))).NewState;
+ state = engine.Reduce(state, MakeSignal(23, DecisionSignalKind.AccountSetupCompletedPart2, T0.AddHours(2).AddSeconds(30))).NewState;
+
+ var verdictSignal = MakeSignal(24, DecisionSignalKind.ClassifierVerdictIssued, T0.AddHours(2).AddMinutes(1),
+ new Dictionary
+ {
+ ["classifier"] = WhiteGlovePart2CompletionClassifier.ClassifierId,
+ ["level"] = "Confirmed",
+ ["score"] = "100",
+ ["reason"] = "all_part2_facts_present",
+ ["inputHash"] = "feedface",
+ });
+
+ var step = engine.Reduce(state, verdictSignal);
+ Assert.Equal(SessionStage.WhiteGloveCompletedPart2, step.NewState.Stage);
+
+ var effect = SingleTimelineEffect(step, "whiteglove_part2_complete");
+ var payload = Assert.IsType>(effect.TypedPayload);
+
+ Assert.Equal($"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed", payload["trigger"]);
+ Assert.Equal(nameof(SessionStage.WhiteGloveCompletedPart2), payload["sessionStage"]);
+
+ var signalsSeen = Assert.IsType>(payload["signalsSeen"]);
+ Assert.Contains("user_aad_sign_in_complete", signalsSeen);
+ Assert.Contains("hello_resolved_part2", signalsSeen);
+ Assert.Contains("desktop_arrived_part2", signalsSeen);
+ Assert.Contains("account_setup_completed_part2", signalsSeen);
+
+ var classifier = Assert.IsType>(payload["classifier"]);
+ Assert.Equal(WhiteGlovePart2CompletionClassifier.ClassifierId, classifier["id"]);
+ Assert.Equal("feedface", classifier["inputHash"]);
+ }
+
+ // ============================================================ enrollment_failed (Part-2 safety)
+
+ [Fact]
+ public void Part2SafetyDeadline_failure_event_carries_audit_trail_with_part2_user_absent_reason()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("sess-p2s", "tenant-p2s")
+ .ToBuilder()
+ .WithStage(SessionStage.WhiteGloveAwaitingUserSignIn)
+ .AddDeadline(new ActiveDeadline(
+ name: DeadlineNames.WhiteGlovePart2Safety,
+ dueAtUtc: T0.AddHours(24),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety,
+ }))
+ .Build();
+
+ var step = engine.Reduce(state, MakeSignal(
+ 30, DecisionSignalKind.DeadlineFired, T0.AddHours(24),
+ new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety }));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+
+ var effect = SingleTimelineEffect(step, "enrollment_failed");
+ var payload = Assert.IsType>(effect.TypedPayload);
+
+ Assert.Equal($"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}", payload["trigger"]);
+ Assert.Equal("part2_user_absent", payload["reason"]);
+ Assert.Equal(nameof(SessionStage.Failed), payload["sessionStage"]);
+ }
+
+ // ============================================================ enrollment_failed (ESP terminal)
+
+ [Fact]
+ public void EspTerminalFailure_failure_event_carries_audit_trail_with_signal_reason()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("sess-esp", "tenant-esp");
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(
+ 40, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(15),
+ new Dictionary { ["reason"] = "policy_apply_timeout" }));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+
+ var effect = SingleTimelineEffect(step, "enrollment_failed");
+ var payload = Assert.IsType>(effect.TypedPayload);
+
+ Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), payload["trigger"]);
+ Assert.Equal("policy_apply_timeout", payload["reason"]);
+ }
+
+ // ============================================================ enrollment_failed (effect infra)
+
+ [Fact]
+ public void EffectInfrastructureFailure_failure_event_carries_audit_trail_with_signal_reason()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("sess-eff", "tenant-eff");
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(
+ 50, DecisionSignalKind.EffectInfrastructureFailure, T0.AddMinutes(2),
+ new Dictionary { ["reason"] = "deadline_scheduler_offline" }));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+
+ var effect = SingleTimelineEffect(step, "enrollment_failed");
+ var payload = Assert.IsType>(effect.TypedPayload);
+
+ Assert.Equal(nameof(DecisionSignalKind.EffectInfrastructureFailure), payload["trigger"]);
+ Assert.Equal("deadline_scheduler_offline", payload["reason"]);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs
index b0b20bc5..3bb80a01 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs
@@ -153,8 +153,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
&& e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
var payload = Assert.IsType>(terminalEffect.TypedPayload);
- Assert.Equal("DecisionEngine", payload["completionSource"]);
- Assert.Equal($"DeadlineFired:{DeadlineNames.FinalizingGrace}", payload["completionTrigger"]);
+ // Field names come from the unified DecisionAuditTrailBuilder (decisionSource /
+ // trigger replace the legacy completionSource / completionTrigger; the rest of the
+ // shape is a strict superset of what enrollment_complete used to publish).
+ Assert.Equal("DecisionEngine", payload["decisionSource"]);
+ Assert.Equal($"DeadlineFired:{DeadlineNames.FinalizingGrace}", payload["trigger"]);
Assert.Equal(nameof(SessionStage.Completed), payload["sessionStage"]);
var signalsSeen = Assert.IsType>(payload["signalsSeen"]);
@@ -168,6 +171,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
// HelloOutcome payload was supplied as "Success" by the InitialAwaitingDesktop fixture.
Assert.Equal("Success", payload["helloOutcome"]);
+
+ // Evidence map carries ordinal + utc per fact for the Inspector's signal-jump.
+ var evidence = Assert.IsType>(payload["signalEvidence"]);
+ var helloEvidence = Assert.IsType>(evidence["helloResolved"]);
+ Assert.True(helloEvidence.ContainsKey("ordinal"));
+ Assert.True(helloEvidence.ContainsKey("utc"));
}
[Fact]
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs
new file mode 100644
index 00000000..877cd688
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs
@@ -0,0 +1,371 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.DecisionCore.Engine
+{
+ ///
+ /// Uniform structured audit-trail payload for terminal / state-changing
+ /// effects emitted by the
+ /// . Replaces the per-emission-site ad-hoc payload shapes
+ /// that previously left events like whiteglove_complete with empty
+ /// EnrollmentEvent.Data on the wire.
+ ///
+ /// The output is meant to be passed verbatim as ;
+ /// emitters (V2 EventTimelineEmitter) hand the dictionary
+ /// directly to . Field ordering uses
+ /// so on-disk Snapshot test outputs are stable.
+ ///
+ ///
+ ///
+ /// Contract for callers:
+ ///
+ /// - is the state AFTER the reduce step that emits the
+ /// event. StepIndex and LastAppliedSignalOrdinal on it correspond to
+ /// the emitting transition's StepIndex and the signal that drove the reduce.
+ /// - is what the caller is committing to — usually
+ /// equals postState.Stage; passed explicitly so the field reflects the
+ /// decision's outcome even if the caller still routes through an intermediate
+ /// stage.
+ /// - mirrors so a
+ /// consumer can correlate the audit-trail event with the journal transition without
+ /// joining tables (e.g. "DeadlineFired:FinalizingGrace",
+ /// "ClassifierVerdictIssued:WhiteGloveSealing:Confirmed").
+ ///
+ ///
+ internal static class DecisionAuditTrailBuilder
+ {
+ ///
+ /// Stable string the audit trail uses for the decisionSource field. Mirrors the
+ /// V2 emitter default (EventTimelineEmitter.SourceId) — keeps backend filters
+ /// like Source == "DecisionEngine" uniform across event-type and audit-trail.
+ ///
+ public const string DecisionSource = "DecisionEngine";
+
+ ///
+ /// Build the audit-trail payload. All optional parameters default to off — the
+ /// resulting dictionary always contains the mandatory anchors (decisionSource,
+ /// trigger, sessionStage, stepIndex, signalsSeen,
+ /// signalEvidence, signalTimestamps, scenario) and adds the
+ /// classifier / failure / context fields only when the corresponding optional argument
+ /// or state fact is present.
+ ///
+ /// State produced by the reducer step (post-transition).
+ /// Decision outcome stage to report (often == postState.Stage).
+ /// The same string the reducer attached to the transition.
+ /// Optional human-readable failure reason — surfaced as reason when set.
+ /// Optional classifier verdict (id + level + score + reason + inputHash).
+ /// Optional classifier-input snapshot (e.g. ) — flattened to a dict via .
+ public static Dictionary Build(
+ DecisionState postState,
+ SessionStage decidedStage,
+ string trigger,
+ string? failureReason = null,
+ ClassifierVerdictInfo? classifier = null,
+ object? classifierInputs = null)
+ {
+ if (postState == null) throw new ArgumentNullException(nameof(postState));
+ if (string.IsNullOrEmpty(trigger)) throw new ArgumentException("Trigger is mandatory.", nameof(trigger));
+
+ var data = new Dictionary(StringComparer.Ordinal)
+ {
+ ["decisionSource"] = DecisionSource,
+ ["trigger"] = trigger,
+ ["sessionStage"] = decidedStage.ToString(),
+ ["stepIndex"] = postState.StepIndex,
+ };
+
+ if (postState.LastAppliedSignalOrdinal >= 0)
+ {
+ data["signalOrdinal"] = postState.LastAppliedSignalOrdinal;
+ }
+
+ // Signal census — both a flat list (cheap rendering / KQL `has` filter) and a
+ // structured evidence map (ordinal + UTC for the Inspector's "jump to signal").
+ var (signalsSeen, signalEvidence, signalTimestamps) = BuildSignalCensus(postState);
+ data["signalsSeen"] = signalsSeen;
+ data["signalEvidence"] = signalEvidence;
+ // signalTimestamps mirrors the legacy V2-parity field name (FinalizingStageTests
+ // pre-existing assertion) — kept verbatim so audit-trail is a strict superset of
+ // what enrollment_complete already published.
+ data["signalTimestamps"] = signalTimestamps;
+
+ // Scenario block — single source of truth for "what kind of enrollment was this".
+ data["scenario"] = BuildScenarioBlock(postState);
+
+ if (classifier != null)
+ {
+ data["classifier"] = classifier.ToDictionary();
+ }
+
+ if (classifierInputs != null)
+ {
+ data["classifierInputs"] = FlattenClassifierInputs(classifierInputs);
+ }
+
+ if (!string.IsNullOrEmpty(failureReason))
+ {
+ data["reason"] = failureReason!;
+ }
+
+ // Hello / IME context — present in the data only when actually known so the
+ // payload doesn't mis-suggest "Hello observed = false" when it's just absent.
+ if (postState.HelloOutcome != null)
+ {
+ data["helloOutcome"] = postState.HelloOutcome.Value;
+ }
+ if (postState.HelloPolicyEnabled != null)
+ {
+ data["helloPolicyEnabled"] = postState.HelloPolicyEnabled.Value;
+ }
+ if (postState.ImeMatchedPatternId != null)
+ {
+ data["imePatternMatchedPatternId"] = postState.ImeMatchedPatternId.Value;
+ }
+
+ return data;
+ }
+
+ ///
+ /// Shorthand to wrap a classifier-verdict signal payload (the parameter dict carried by
+ /// ) into a typed
+ /// . Returns null if the payload is missing
+ /// the mandatory classifier + level keys — caller treats null as "no
+ /// classifier section in the audit trail" rather than synthesising fake values.
+ ///
+ public static ClassifierVerdictInfo? VerdictFromSignalPayload(IReadOnlyDictionary? payload)
+ {
+ if (payload == null) return null;
+ if (!payload.TryGetValue("classifier", out var id) || string.IsNullOrEmpty(id)) return null;
+ if (!payload.TryGetValue("level", out var levelRaw) || string.IsNullOrEmpty(levelRaw)) return null;
+
+ int score = 0;
+ if (payload.TryGetValue("score", out var scoreRaw))
+ {
+ int.TryParse(scoreRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out score);
+ }
+ var reason = payload.TryGetValue("reason", out var r) ? r : id;
+ var inputHash = payload.TryGetValue("inputHash", out var h) ? h : string.Empty;
+
+ return new ClassifierVerdictInfo(id, levelRaw, score, reason, inputHash);
+ }
+
+ // --- helpers ------------------------------------------------------------------------
+
+ private static (List SignalsSeen, Dictionary SignalEvidence, Dictionary SignalTimestamps) BuildSignalCensus(DecisionState s)
+ {
+ var seen = new List(8);
+ var evidence = new Dictionary(StringComparer.Ordinal);
+ var timestamps = new Dictionary(StringComparer.Ordinal);
+
+ // Flow signals (Classic / SelfDeploying paths).
+ if (s.HelloResolvedUtc != null)
+ {
+ seen.Add("hello_resolved");
+ timestamps["helloResolved"] = FormatUtc(s.HelloResolvedUtc.Value);
+ evidence["helloResolved"] = TimestampedEvidence(s.HelloResolvedUtc);
+ }
+ if (s.DesktopArrivedUtc != null)
+ {
+ seen.Add("desktop_arrived");
+ timestamps["desktopArrived"] = FormatUtc(s.DesktopArrivedUtc.Value);
+ evidence["desktopArrived"] = TimestampedEvidence(s.DesktopArrivedUtc);
+ }
+ if (s.EspFinalExitUtc != null)
+ {
+ seen.Add("esp_final_exit");
+ timestamps["espFinalExit"] = FormatUtc(s.EspFinalExitUtc.Value);
+ evidence["espFinalExit"] = TimestampedEvidence(s.EspFinalExitUtc);
+ }
+ if (s.ImeMatchedPatternId != null)
+ {
+ seen.Add("ime_pattern_matched");
+ evidence["imePatternMatched"] = OrdinalEvidence(s.ImeMatchedPatternId.SourceSignalOrdinal);
+ }
+ if (s.UserAadSignInCompleteUtc != null)
+ {
+ seen.Add("user_aad_sign_in_complete");
+ timestamps["userAadSignInComplete"] = FormatUtc(s.UserAadSignInCompleteUtc.Value);
+ evidence["userAadSignInComplete"] = TimestampedEvidence(s.UserAadSignInCompleteUtc);
+ }
+ if (s.SystemRebootUtc != null)
+ {
+ seen.Add("system_reboot");
+ timestamps["systemReboot"] = FormatUtc(s.SystemRebootUtc.Value);
+ evidence["systemReboot"] = TimestampedEvidence(s.SystemRebootUtc);
+ }
+ if (s.AccountSetupEnteredUtc != null)
+ {
+ seen.Add("account_setup_entered");
+ timestamps["accountSetupEntered"] = FormatUtc(s.AccountSetupEnteredUtc.Value);
+ evidence["accountSetupEntered"] = TimestampedEvidence(s.AccountSetupEnteredUtc);
+ }
+
+ // WhiteGlove Part-1 sealing signals (engine-internal observations, no UTC fact).
+ if (s.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen?.Value == true)
+ {
+ seen.Add("shellcore_whiteglove_success");
+ evidence["shellcoreWhiteGloveSuccess"] = OrdinalEvidence(s.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen.SourceSignalOrdinal);
+ }
+ if (s.ScenarioObservations.WhiteGloveSealingPatternSeen?.Value == true)
+ {
+ seen.Add("whiteglove_sealing_pattern");
+ evidence["whiteGloveSealingPattern"] = OrdinalEvidence(s.ScenarioObservations.WhiteGloveSealingPatternSeen.SourceSignalOrdinal);
+ }
+ if (s.ScenarioObservations.AadUserJoinWithUserObserved != null)
+ {
+ seen.Add(s.ScenarioObservations.AadUserJoinWithUserObserved.Value
+ ? "aad_user_joined_with_user"
+ : "aad_user_joined_device_only");
+ evidence["aadUserJoinObserved"] = new Dictionary(StringComparer.Ordinal)
+ {
+ ["ordinal"] = s.ScenarioObservations.AadUserJoinWithUserObserved.SourceSignalOrdinal,
+ ["withUser"] = s.ScenarioObservations.AadUserJoinWithUserObserved.Value,
+ };
+ }
+
+ // WhiteGlove Part-2 facts.
+ if (s.HelloResolvedPart2Utc != null)
+ {
+ seen.Add("hello_resolved_part2");
+ timestamps["helloResolvedPart2"] = FormatUtc(s.HelloResolvedPart2Utc.Value);
+ evidence["helloResolvedPart2"] = TimestampedEvidence(s.HelloResolvedPart2Utc);
+ }
+ if (s.DesktopArrivedPart2Utc != null)
+ {
+ seen.Add("desktop_arrived_part2");
+ timestamps["desktopArrivedPart2"] = FormatUtc(s.DesktopArrivedPart2Utc.Value);
+ evidence["desktopArrivedPart2"] = TimestampedEvidence(s.DesktopArrivedPart2Utc);
+ }
+ if (s.AccountSetupCompletedPart2Utc != null)
+ {
+ seen.Add("account_setup_completed_part2");
+ timestamps["accountSetupCompletedPart2"] = FormatUtc(s.AccountSetupCompletedPart2Utc.Value);
+ evidence["accountSetupCompletedPart2"] = TimestampedEvidence(s.AccountSetupCompletedPart2Utc);
+ }
+
+ return (seen, evidence, timestamps);
+ }
+
+ private static Dictionary BuildScenarioBlock(DecisionState s)
+ {
+ var block = new Dictionary(StringComparer.Ordinal);
+ if (s.ScenarioProfile != null)
+ {
+ block["mode"] = s.ScenarioProfile.Mode.ToString();
+ block["joinMode"] = s.ScenarioProfile.JoinMode.ToString();
+ block["espConfig"] = s.ScenarioProfile.EspConfig.ToString();
+ block["preProvisioningSide"] = s.ScenarioProfile.PreProvisioningSide.ToString();
+ block["confidence"] = s.ScenarioProfile.Confidence.ToString();
+ }
+ if (s.CurrentEnrollmentPhase != null)
+ {
+ block["currentPhase"] = s.CurrentEnrollmentPhase.Value.ToString();
+ }
+ return block;
+ }
+
+ ///
+ /// Reflect the public properties of a classifier-input snapshot record into a flat
+ /// Dictionary<string, object>. Keeps the audit trail self-describing
+ /// without coupling to a specific snapshot type — works for both
+ /// and the future Part-2 snapshot.
+ /// Property names are camelCased so the JSON-on-the-wire matches the surrounding
+ /// audit-trail style.
+ ///
+ private static Dictionary FlattenClassifierInputs(object snapshot)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var prop in snapshot.GetType().GetProperties())
+ {
+ if (!prop.CanRead) continue;
+ var value = prop.GetValue(snapshot);
+ if (value == null) continue;
+ var key = ToCamelCase(prop.Name);
+ if (value is DateTime dt)
+ {
+ result[key] = FormatUtc(dt);
+ }
+ else if (value is Enum)
+ {
+ result[key] = value.ToString()!;
+ }
+ else
+ {
+ result[key] = value;
+ }
+ }
+ return result;
+ }
+
+ private static Dictionary TimestampedEvidence(SignalFact? fact)
+ {
+ // Caller guards null; this overload exists for the symmetric SignalFact
+ // case so we capture both the ordinal and the canonicalized UTC stamp in one go.
+ var dict = new Dictionary(StringComparer.Ordinal)
+ {
+ ["ordinal"] = fact!.SourceSignalOrdinal,
+ };
+ if (fact.Value is DateTime dt)
+ {
+ dict["utc"] = FormatUtc(dt);
+ }
+ else if (fact.Value != null)
+ {
+ dict["value"] = fact.Value!;
+ }
+ return dict;
+ }
+
+ private static Dictionary OrdinalEvidence(long ordinal) =>
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["ordinal"] = ordinal,
+ };
+
+ private static string FormatUtc(DateTime utc) =>
+ DateTime.SpecifyKind(utc, DateTimeKind.Utc)
+ .ToString("o", CultureInfo.InvariantCulture);
+
+ private static string ToCamelCase(string s)
+ {
+ if (string.IsNullOrEmpty(s) || char.IsLower(s[0])) return s;
+ return char.ToLowerInvariant(s[0]) + s.Substring(1);
+ }
+ }
+
+ ///
+ /// Immutable record of a classifier verdict consumed by
+ /// . Lifted out of the legacy parameter-bag
+ /// shape so callers cannot accidentally drop a key on the audit trail.
+ ///
+ internal sealed class ClassifierVerdictInfo
+ {
+ public ClassifierVerdictInfo(string id, string level, int score, string reason, string inputHash)
+ {
+ Id = id ?? throw new ArgumentNullException(nameof(id));
+ Level = level ?? throw new ArgumentNullException(nameof(level));
+ Score = score;
+ Reason = reason ?? string.Empty;
+ InputHash = inputHash ?? string.Empty;
+ }
+
+ public string Id { get; }
+ public string Level { get; }
+ public int Score { get; }
+ public string Reason { get; }
+ public string InputHash { get; }
+
+ public Dictionary ToDictionary() =>
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["id"] = Id,
+ ["level"] = Level,
+ ["score"] = Score,
+ ["reason"] = Reason,
+ ["inputHash"] = InputHash,
+ };
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
index 9c7da059..7713cb55 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
@@ -470,10 +470,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// deadline path and the SelfDeploying DeviceSetupProvisioningComplete path. Restores
/// V1 backend-side audit-trail parity (882fef64 debrief follow-up): Parameters carry
/// just eventType (legacy contract), and a structured
- /// dictionary feeds EnrollmentEvent.Data with signalsSeen,
- /// signalTimestamps, completionSource, completionTrigger, plus the
- /// resolved Hello / scenario context. Lets backend consumers reconstruct "which signals
- /// drove completion" without re-reading the agent's final-status.json.
+ /// dictionary built by feeds
+ /// EnrollmentEvent.Data with signalsSeen, signalEvidence,
+ /// signalTimestamps, decisionSource, trigger, plus the resolved
+ /// Hello / scenario context. Lets backend consumers reconstruct "which signals drove
+ /// completion" without re-reading the agent's final-status.json.
///
/// Post-transition state (Stage=Completed, Outcome=EnrollmentComplete).
///
@@ -490,73 +491,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
["eventType"] = "enrollment_complete",
},
- typedPayload: BuildEnrollmentCompleteAuditTrail(state, completionTrigger));
+ typedPayload: DecisionAuditTrailBuilder.Build(
+ postState: state,
+ decidedStage: state.Stage,
+ trigger: completionTrigger));
}
-
- private static Dictionary BuildEnrollmentCompleteAuditTrail(
- DecisionState state,
- string completionTrigger)
- {
- var signalsSeen = new List(8);
- var signalTimestamps = new Dictionary(StringComparer.Ordinal);
-
- if (state.HelloResolvedUtc != null)
- {
- signalsSeen.Add("hello_resolved");
- signalTimestamps["helloResolved"] = FormatUtc(state.HelloResolvedUtc.Value);
- }
- if (state.DesktopArrivedUtc != null)
- {
- signalsSeen.Add("desktop_arrived");
- signalTimestamps["desktopArrived"] = FormatUtc(state.DesktopArrivedUtc.Value);
- }
- if (state.EspFinalExitUtc != null)
- {
- signalsSeen.Add("esp_final_exit");
- signalTimestamps["espFinalExit"] = FormatUtc(state.EspFinalExitUtc.Value);
- }
- if (state.ImeMatchedPatternId != null)
- {
- signalsSeen.Add("ime_pattern_matched");
- }
- if (state.UserAadSignInCompleteUtc != null)
- {
- signalsSeen.Add("user_aad_sign_in_complete");
- signalTimestamps["userAadSignInComplete"] = FormatUtc(state.UserAadSignInCompleteUtc.Value);
- }
-
- var data = new Dictionary(StringComparer.Ordinal)
- {
- ["completionSource"] = "DecisionEngine",
- ["completionTrigger"] = completionTrigger,
- ["signalsSeen"] = signalsSeen,
- ["signalTimestamps"] = signalTimestamps,
- ["sessionStage"] = state.Stage.ToString(),
- };
-
- if (state.HelloOutcome != null)
- {
- data["helloOutcome"] = state.HelloOutcome.Value;
- }
- if (state.HelloPolicyEnabled != null)
- {
- data["helloPolicyEnabled"] = state.HelloPolicyEnabled.Value;
- }
- if (state.ImeMatchedPatternId != null)
- {
- data["imePatternMatchedPatternId"] = state.ImeMatchedPatternId.Value;
- }
- if (state.ScenarioProfile != null)
- {
- data["enrollmentMode"] = state.ScenarioProfile.Mode.ToString();
- data["enrollmentJoinMode"] = state.ScenarioProfile.JoinMode.ToString();
- }
-
- return data;
- }
-
- private static string FormatUtc(DateTime utc) =>
- DateTime.SpecifyKind(utc, DateTimeKind.Utc)
- .ToString("o", System.Globalization.CultureInfo.InvariantCulture);
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
index 73183aeb..83295288 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
@@ -78,7 +78,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
["eventType"] = "enrollment_failed",
["reason"] = reason,
- }),
+ },
+ typedPayload: DecisionAuditTrailBuilder.Build(
+ postState: newState,
+ decidedStage: SessionStage.Failed,
+ trigger: nameof(DecisionSignalKind.EspTerminalFailure),
+ failureReason: reason)),
};
return new DecisionStep(newState, transition, effects);
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index 5ac2f316..c9dba3e7 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -153,7 +153,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
["eventType"] = "enrollment_failed",
["reason"] = reason,
- });
+ },
+ typedPayload: DecisionAuditTrailBuilder.Build(
+ postState: newState,
+ decidedStage: SessionStage.Failed,
+ trigger: nameof(DecisionSignalKind.EffectInfrastructureFailure),
+ failureReason: reason));
var transition = BuildTakenTransition(
before: state,
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
index 3ef7d79d..1258252c 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs
@@ -132,11 +132,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed");
+ var verdictInfo = new ClassifierVerdictInfo(classifier, level.ToString(), score, reason, inputHash);
var sealedEffects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
- parameters: new Dictionary { ["eventType"] = "whiteglove_complete" }),
+ parameters: new Dictionary { ["eventType"] = "whiteglove_complete" },
+ typedPayload: DecisionAuditTrailBuilder.Build(
+ postState: sealedState,
+ decidedStage: SessionStage.WhiteGloveSealed,
+ trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed",
+ classifier: verdictInfo,
+ classifierInputs: BuildWhiteGloveSealingSnapshot(sealedState))),
};
return new DecisionStep(sealedState, sealedTransition, sealedEffects);
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
index 454f0790..eb186ba4 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
@@ -48,6 +48,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn");
+ var resumedTrigger = "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn";
+ var resumedAudit = DecisionAuditTrailBuilder.Build(
+ postState: newState,
+ decidedStage: SessionStage.WhiteGloveAwaitingUserSignIn,
+ trigger: resumedTrigger);
+ // Augment with bridge-specific scalars so the resumedAt timestamp + the recovering
+ // collector's source label are preserved on the wire (kept on Parameters too for
+ // tests / consumers that read scalar keys).
+ resumedAudit["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
+ resumedAudit["sourceOrigin"] = signal.SourceOrigin ?? string.Empty;
+
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
@@ -56,9 +67,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
parameters: new Dictionary
{
["eventType"] = "whiteglove_resumed",
- ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"),
+ ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
- }),
+ },
+ typedPayload: resumedAudit),
};
return new DecisionStep(newState, transition, effects);
@@ -140,7 +152,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
["eventType"] = "enrollment_failed",
["reason"] = "part2_user_absent",
- }),
+ },
+ typedPayload: DecisionAuditTrailBuilder.Build(
+ postState: newState,
+ decidedStage: SessionStage.Failed,
+ trigger: $"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}",
+ failureReason: "part2_user_absent")),
};
return new DecisionStep(newState, transition, effects);
@@ -243,11 +260,19 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed");
+ var part2Verdict = new ClassifierVerdictInfo(
+ WhiteGlovePart2CompletionClassifier.ClassifierId, level.ToString(), score, reason, inputHash);
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
- parameters: new Dictionary { ["eventType"] = "whiteglove_part2_complete" }),
+ parameters: new Dictionary { ["eventType"] = "whiteglove_part2_complete" },
+ typedPayload: DecisionAuditTrailBuilder.Build(
+ postState: sealedState,
+ decidedStage: SessionStage.WhiteGloveCompletedPart2,
+ trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed",
+ classifier: part2Verdict,
+ classifierInputs: BuildWhiteGlovePart2CompletionSnapshot(sealedState))),
};
return new DecisionStep(sealedState, sealedTransition, effects);