fix(v2): 882fef64 followup — restore V1 audit trail on enrollment_complete

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-25 21:55:53 +02:00
parent 8a9a9ffe82
commit 4de2677a5a
4 changed files with 133 additions and 6 deletions
@@ -1 +1 @@
1166
1168
@@ -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<string, string> { [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<Dictionary<string, object>>(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<List<string>>(payload["signalsSeen"]);
Assert.Contains("hello_resolved", signalsSeen);
Assert.Contains("desktop_arrived", signalsSeen);
var timestamps = Assert.IsType<Dictionary<string, object>>(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()
{
@@ -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(
/// <summary>
/// Terminal <c>enrollment_complete</c> 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 <c>eventType</c> (legacy contract), and a structured <see cref="DecisionEffect.TypedPayload"/>
/// dictionary feeds <c>EnrollmentEvent.Data</c> with <c>signalsSeen</c>,
/// <c>signalTimestamps</c>, <c>completionSource</c>, <c>completionTrigger</c>, plus the
/// resolved Hello / scenario context. Lets backend consumers reconstruct "which signals
/// drove completion" without re-reading the agent's <c>final-status.json</c>.
/// </summary>
/// <param name="state">Post-transition state (Stage=Completed, Outcome=EnrollmentComplete).</param>
/// <param name="completionTrigger">
/// The trigger label that was passed to <see cref="DecisionEngine.BuildTakenTransition"/>
/// for this step (e.g. <c>"DeadlineFired:FinalizingGrace"</c> or
/// <c>"DeviceSetupProvisioningComplete"</c>) — keeps the audit trail self-describing
/// without requiring the consumer to re-derive it from the StepIndex.
/// </param>
internal static DecisionEffect BuildEnrollmentCompleteEffect(DecisionState state, string completionTrigger)
{
return new DecisionEffect(
kind: DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = "enrollment_complete",
});
},
typedPayload: BuildEnrollmentCompleteAuditTrail(state, completionTrigger));
}
private static Dictionary<string, object> BuildEnrollmentCompleteAuditTrail(
DecisionState state,
string completionTrigger)
{
var signalsSeen = new List<string>(8);
var signalTimestamps = new Dictionary<string, object>(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<string, object>(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);
}
}
@@ -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);
}