From 4de2677a5ab322a8e0ece71abc4bd36f1cda1ae2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 25 Apr 2026 21:55:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(v2):=20882fef64=20followup=20=E2=80=94=20re?= =?UTF-8?q?store=20V1=20audit=20trail=20on=20enrollment=5Fcomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2's reducer-emitted enrollment_complete event landed in the backend with empty Data{} — V1 had carried a signal audit trail (signalsSeen, signalTimestamps, completionSource, helloOutcome) that downstream consumers relied on to reconstruct "which signals drove completion" without re-reading the agent's local final-status.json. BuildEnrollmentCompleteEffect now takes (state, completionTrigger) and attaches a structured TypedPayload. EventTimelineEmitter passes that dict verbatim into EnrollmentEvent.Data, so the wire shape mirrors V1's audit trail (plus enrollmentMode/JoinMode from the V2 ScenarioProfile). Both kernel-driven completion paths now carry the trail: - DecisionEngine.Classic: DeadlineFired:FinalizingGrace - DecisionEngine.SelfDeploying: DeviceSetupProvisioningComplete AdminPreemptionDetected keeps its existing adminAction/source params — different completion semantics, leave alone. Tests: DecisionCore 232 (+1), V2.Core 732, V1.Core 507, all green. Solution build 0 warnings, 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../FinalizingStageTests.cs | 41 ++++++++ .../Engine/DecisionEngine.Classic.cs | 94 ++++++++++++++++++- .../Engine/DecisionEngine.SelfDeploying.cs | 2 +- 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 0a7f95a2..ddbefde5 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1166 +1168 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs index 46e7a341..b0b20bc5 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs @@ -129,6 +129,47 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.NotNull(terminalEffect); } + [Fact] + public void FinalizingGraceDeadline_enrollment_complete_carries_v1_audit_trail_in_typed_payload() + { + // 882fef64 debrief follow-up: V2 had regressed by emitting enrollment_complete with + // empty Data — V1's audit trail (signalsSeen, signalTimestamps, completionSource, + // helloOutcome) had only been written to the local final-status.json. The reducer + // now restores parity by attaching a structured TypedPayload that the + // EventTimelineEmitter passes verbatim to EnrollmentEvent.Data. + var engine = new DecisionEngine(); + var state = InitialAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5), null)).NewState; + + var step = engine.Reduce( + state, + MakeSignal(6, DecisionSignalKind.DeadlineFired, T0.AddMinutes(5).AddSeconds(5), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace })); + + var terminalEffect = Assert.Single( + step.Effects, + e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && 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"]); + Assert.Equal(nameof(SessionStage.Completed), payload["sessionStage"]); + + var signalsSeen = Assert.IsType>(payload["signalsSeen"]); + Assert.Contains("hello_resolved", signalsSeen); + Assert.Contains("desktop_arrived", signalsSeen); + + var timestamps = Assert.IsType>(payload["signalTimestamps"]); + // Hello resolved at T0+4min, Desktop arrived at T0+5min — both ISO-8601 round-trip strings. + Assert.Equal(T0.AddMinutes(4).ToString("o", System.Globalization.CultureInfo.InvariantCulture), timestamps["helloResolved"]); + Assert.Equal(T0.AddMinutes(5).ToString("o", System.Globalization.CultureInfo.InvariantCulture), timestamps["desktopArrived"]); + + // HelloOutcome payload was supplied as "Success" by the InitialAwaitingDesktop fixture. + Assert.Equal("Success", payload["helloOutcome"]); + } + [Fact] public void HelloResolved_when_Desktop_already_arrived_also_routes_through_Finalizing() { diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index cbdf8851..f2db5b39 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -437,7 +437,7 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: $"DeadlineFired:{DeadlineNames.FinalizingGrace}"); - var effects = new[] { BuildEnrollmentCompleteEffect() }; + var effects = new[] { BuildEnrollmentCompleteEffect(newState, $"DeadlineFired:{DeadlineNames.FinalizingGrace}") }; return new DecisionStep(newState, transition, effects); } @@ -464,12 +464,98 @@ namespace AutopilotMonitor.DecisionCore.Engine ["message"] = $"Phase: {phase}", }); - private static DecisionEffect BuildEnrollmentCompleteEffect() => - new DecisionEffect( + /// + /// Terminal enrollment_complete effect emitted by the Classic FinalizingGrace + /// 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. + /// + /// Post-transition state (Stage=Completed, Outcome=EnrollmentComplete). + /// + /// The trigger label that was passed to + /// for this step (e.g. "DeadlineFired:FinalizingGrace" or + /// "DeviceSetupProvisioningComplete") — keeps the audit trail self-describing + /// without requiring the consumer to re-derive it from the StepIndex. + /// + internal static DecisionEffect BuildEnrollmentCompleteEffect(DecisionState state, string completionTrigger) + { + return new DecisionEffect( kind: DecisionEffectKind.EmitEventTimelineEntry, parameters: new Dictionary { ["eventType"] = "enrollment_complete", - }); + }, + typedPayload: BuildEnrollmentCompleteAuditTrail(state, 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.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs index 98d879db..774ce25c 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -105,7 +105,7 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)); - var effects = new[] { BuildEnrollmentCompleteEffect() }; + var effects = new[] { BuildEnrollmentCompleteEffect(newState, nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)) }; return new DecisionStep(newState, transition, effects); }