From 5eb95868d8fd5cdf8164b8fca834ea16a61d7049 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 21 May 2026 23:42:10 +0200 Subject: [PATCH] fix(decisioncore): defang SelfDeploying terminal via 5-min deadline + UI phase coverage Session 88a53223-9795-4188-8352-7df9f0af9bb7 (v1 Classic UserDriven, AAD Joined, SkipUserStatusPage=false) was wrongly classified as mode=SelfDeploying after the ProvisioningStatusTracker 30-s fallback fired DeviceSetupProvisioningComplete before Windows transitioned DeviceSetup -> AccountSetup. The signal alone was terminal in the old reducer; the absence of AccountSetupEnteredUtc at signal time was sufficient to commit terminal SelfDeploying. New rule: DeviceSetupProvisioningComplete is no longer terminal. It records a new DeviceSetupResolvedUtc anchor and (cancel-then-)arms the DeviceOnlyEspDetection deadline for 5 min. The deadline-fired handler is the sole SelfDeploying-terminal entry point and re-checks six guards (stale: no-anchor / not-armed / DueAtUtc-mismatch; race: Stage-terminal / AccountSetup-entered / monotonic Mode conflict) before classifying. Any dead-end after the DueAtUtc match state-side CancelDeadline()s the past-due deadline so snapshot reload doesn't re-arm via the scheduler. RealmJoin-deferred release (CompleteIfDeferredOrBookkeep) re-checks the same guards, resets DeviceOnlyDeployment hypothesis to Unknown on bailout, and operates on preparedBuilder.* (not state.*) so WithResolved / WithTimeoutOutcome facts survive the bailout. UI phase coverage closed: phase_transition(FinalizingSetup) and phase_transition(Complete) emitted at every terminal-success site (Classic FinalizingGrace, SelfDeploying deadline-terminal, AdminPreemption-Succeeded, RJ-deferred completion) so the Web timeline opens Finalizing + Complete bars universally - was missing entirely for SelfDeploying / AdminPreemption paths. Other changes: - Removed ApplyDeviceSetupProvisioningComplete (dead code under defang), added ApplySelfDeployingDeadlineConfirmed with monotonic guard. - New RealmJoinFacts.ClearSelfDeployingDeferred() for the RJ-bailout path. - DecisionState schema bumped v3 -> v4 with additive nullable DeviceSetupResolvedUtc field. StateSerializer v3-deserialize-compat verified. - DeviceSetupProvisioningComplete added to DecisionStateSignalCensus so the enrollment_complete audit trail surfaces the anchor. Test coverage: - New SelfDeployingDeadlineTerminalTests.cs (12 scenarios): happy path, all six guards including snapshot-migration rollout race + DueAtUtc cancel-rearm race, replay idempotency, audit-census wiring, RJ-deferred release races (AccountSetup-arrives / monotonic / Timeout-variant). - Updated 9 existing test files for new semantics; 2 fixtures now drive DeadlineFired terminal explicitly. - ReplayHarness + ClassifierAwareReplayHarness set AgentBootUtc to the first signal's OccurredAtUtc so fixture scenarios run in their own time-bubble (replay-safety floor still tested directly by BootAnchorDeadlineFloorTests). Tests: DecisionCore 345, V2.Core 1135, Backend 1937 - all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../BootAnchorDeadlineFloorTests.cs | 31 +- .../DecisionStateSnapshotBuilderTests.cs | 12 +- .../Harness/ClassifierAwareReplayHarness.cs | 6 +- .../Harness/ReplayHarness.cs | 10 +- .../RealmJoinGateTests.cs | 61 +- .../ReducerTimelineEffectTests.cs | 25 +- .../Scenarios/ClassicScenarioTests.cs | 11 +- .../Scenarios/SelfDeployingScenarioTests.cs | 36 +- .../SelfDeployingDeadlineTerminalTests.cs | 520 ++++++++++++++++++ .../SerializationTests.cs | 60 ++ .../EnrollmentScenarioProfileUpdaterTests.cs | 64 ++- .../Engine/DecisionEngine.Classic.cs | 34 +- .../Engine/DecisionEngine.RealmJoin.cs | 70 ++- .../Engine/DecisionEngine.SelfDeploying.cs | 340 +++++++++--- .../Engine/DecisionEngine.Shared.cs | 32 +- .../Engine/DecisionStateSignalCensus.cs | 10 + .../Engine/DecisionStateSnapshotBuilder.cs | 1 + .../State/DecisionState.cs | 20 +- .../State/DecisionStateBuilder.cs | 3 + .../State/EnrollmentScenarioProfileUpdater.cs | 22 +- .../State/RealmJoinFacts.cs | 18 + .../selfdeploying-esp-exit-unknown-v1.jsonl | 14 +- .../selfdeploying-happy-v1.jsonl | 9 +- 25 files changed, 1217 insertions(+), 196 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index bde83afb..9761bfa4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -855 +861 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 581aa820..1a7d37ee 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1396 +1397 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs index f240c3dc..1352ad5e 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs @@ -68,22 +68,32 @@ namespace AutopilotMonitor.DecisionCore.Tests } [Fact] - public void DeviceOnlyEspDetection_armed_from_replayed_DeviceSetup_floors_at_AgentBootUtc() + public void DeviceOnlyEspDetection_armed_from_replayed_DeviceSetupProvisioningComplete_floors_at_AgentBootUtc() { + // Plan v9 88a53223 defang — the DeviceOnlyEspDetection deadline is now armed by + // DeviceSetupProvisioningComplete (DeviceSetup-END), NOT by EspPhaseChanged(DeviceSetup) + // (DeviceSetup-START). The replay-safety floor still applies: a replayed signal with a + // past OccurredAtUtc must not collapse the 5-min deadline to immediate-fire at boot. var engine = new DecisionEngine(); var state = FreshAtBoot(); state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, AgentBoot)).NewState; + // Get into a DeviceSetup state with a live signal so the replayed terminal-arm signal + // can be evaluated in isolation. + state = engine.Reduce(state, + MakeSignal(1, DecisionSignalKind.EspPhaseChanged, AgentBoot.AddSeconds(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; - // Replayed DeviceSetup phase change carrying a 30-min-old CMTrace timestamp. - var replayed = MakeSignal(1, DecisionSignalKind.EspPhaseChanged, LogPast, - new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" }); + // Replayed DeviceSetupProvisioningComplete carrying a 30-min-old CMTrace timestamp. + var replayed = MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, LogPast); var step = engine.Reduce(state, replayed); var devOnly = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); - // Without the floor: dueAt = LogPast + 5min = 9:35 → fires immediately, classifier - // promotes DeviceOnlyDeployment to Strong. With the floor: dueAt = AgentBoot + 5min. + // Without the floor: dueAt = LogPast + 5min → fires immediately at boot → false-positive + // SelfDeploying terminal classification on what may have been a Classic flow. With the + // floor: dueAt = AgentBoot + 5min, giving subsequent live signals time to arrive. Assert.Equal(AgentBoot.AddMinutes(5), devOnly.DueAtUtc); + Assert.NotNull(step.NewState.DeviceSetupResolvedUtc); } [Fact] @@ -123,16 +133,19 @@ namespace AutopilotMonitor.DecisionCore.Tests { // Sanity: the floor is one-sided. A signal whose OccurredAtUtc is *after* the // boot anchor gets its raw timestamp through — the floor only kicks in when the - // signal is older than the boot. + // signal is older than the boot. Verified on the new DeviceSetupProvisioningComplete + // arm-point per Plan v9. var engine = new DecisionEngine(); var state = FreshAtBoot(); state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, AgentBoot)).NewState; + state = engine.Reduce(state, + MakeSignal(1, DecisionSignalKind.EspPhaseChanged, AgentBoot.AddSeconds(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; var liveAfterBoot = AgentBoot.AddMinutes(2); var step = engine.Reduce(state, - MakeSignal(1, DecisionSignalKind.EspPhaseChanged, liveAfterBoot, - new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })); + MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, liveAfterBoot)); var devOnly = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); Assert.Equal(liveAfterBoot.AddMinutes(5), devOnly.DueAtUtc); diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs index 92dcf55c..61aa3bd3 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs @@ -103,7 +103,9 @@ namespace AutopilotMonitor.DecisionCore.Tests // and only promote in-flight installs on ESP-Apps timeout → 12 slots. // Session 330f73f3 fix (2026-05-18) added `accountSetupProvisioningSucceededUtc` // as the strong post-AccountSetup gate → 13 slots. - Assert.Equal(13, facts.Count); + // 88a53223 SelfDeploying defang (Plan v9) added `deviceSetupResolvedUtc` so the + // new DeviceOnlyEspDetection deadline arms from DeviceSetup-END not -START → 14. + Assert.Equal(14, facts.Count); Assert.All(facts.Values, v => Assert.Null(v)); } @@ -215,9 +217,11 @@ namespace AutopilotMonitor.DecisionCore.Tests // `lastFailureTrigger` added for the EnrollmentTerminationHandler's // 4-check discriminator → 12. Session 330f73f3 fix (2026-05-18) added // `accountSetupProvisioningSucceededUtc` as the strong post-AccountSetup - // gate → 13. If this number ever changes, both the count expectation AND - // the actual snapshot output need to evolve in lockstep. - Assert.Equal(13, expectedFactKeys.Count); + // gate → 13. 88a53223 SelfDeploying defang (Plan v9, 2026-05-21) added + // `deviceSetupResolvedUtc` as the DeviceSetup-END anchor for the new + // deadline arm-point → 14. If this number ever changes, both the count + // expectation AND the actual snapshot output need to evolve in lockstep. + Assert.Equal(14, expectedFactKeys.Count); var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t")); var facts = (Dictionary)snapshot["facts"]!; diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs index d3f0b1ad..31ba907c 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs @@ -39,7 +39,11 @@ namespace AutopilotMonitor.DecisionCore.Tests.Harness { if (signals == null) throw new ArgumentNullException(nameof(signals)); - var state = DecisionState.CreateInitial(sessionId, tenantId); + // Plan v9: match ReplayHarness — set AgentBootUtc to the first signal's OccurredAtUtc + // so fixture-driven scenarios run in their own time-bubble (DueAtUtc matches subsequent + // DeadlineFired times in the fixture). + var agentBootUtc = signals.Count > 0 ? signals[0].OccurredAtUtc : System.DateTime.UtcNow; + var state = DecisionState.CreateInitial(sessionId, tenantId, agentBootUtc); var allTransitions = new List(signals.Count * 2); ClassifierRunStats = new Dictionary(); diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs index b14f0091..d4c0cf58 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs @@ -38,7 +38,15 @@ namespace AutopilotMonitor.DecisionCore.Tests.Harness throw new ArgumentNullException(nameof(signals)); } - var state = DecisionState.CreateInitial(sessionId, tenantId); + // Plan v9 88a53223 defang — the harness sets AgentBootUtc to the first signal's + // OccurredAtUtc (or DateTime.UtcNow when empty) so fixture-driven scenarios run in + // their own time-bubble. Without this, replay-safety flooring (EffectiveDeadlineBase) + // would set every deadline's DueAtUtc to ~NOW + window, then DeadlineFired signals + // authored with fixture-time OccurredAtUtc would trip the new stale-fire DueAtUtc + // mismatch guard (Plan v9 F1). Production replay-safety is exercised directly by + // BootAnchorDeadlineFloorTests with explicit AgentBootUtc construction. + var agentBootUtc = signals.Count > 0 ? signals[0].OccurredAtUtc : DateTime.UtcNow; + var state = DecisionState.CreateInitial(sessionId, tenantId, agentBootUtc); var transitions = new List(signals.Count); foreach (var signal in signals) diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs index 6eba7266..b61bac79 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -196,27 +196,41 @@ namespace AutopilotMonitor.DecisionCore.Tests // ============================================================== SelfDeploying flow [Fact] - public void SelfDeploying_DeviceSetupProvisioningComplete_with_realmjoin_open_completes_immediately() + public void SelfDeploying_DeviceSetupProvisioningComplete_with_realmjoin_open_armsDeadline_thenDeadlineFiredCompletes() { - // Baseline — RJ never detected → SelfDeploying terminal path completes directly. + // Baseline — RJ never detected → SelfDeploying terminal path completes via the new + // 5-min deadline (Plan v9 88a53223 defang). The signal itself is no longer terminal; + // it just arms the deadline. Then DeadlineFired drives the terminal transition. var engine = new DecisionEngine(); var state = DecisionState.CreateInitial("rj-sd", "rj-tenant", T0); state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; - var step = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(2))); + // Signal arms the deadline; Stage stays EspDeviceSetup. + var signalStep = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(2))); + Assert.Equal(SessionStage.EspDeviceSetup, signalStep.NewState.Stage); + Assert.NotNull(signalStep.NewState.DeviceSetupResolvedUtc); + var deadline = Assert.Single(signalStep.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(T0.AddMinutes(7), deadline.DueAtUtc); + + // DeadlineFired (OccurredAtUtc = DueAtUtc per scheduler contract) → terminal. + var step = engine.Reduce(signalStep.NewState, MakeSignal(3, DecisionSignalKind.DeadlineFired, T0.AddMinutes(7), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); Assert.Equal(SessionStage.Completed, step.NewState.Stage); Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome); + Assert.Equal(EnrollmentMode.SelfDeploying, step.NewState.ScenarioProfile.Mode); + Assert.Equal("selfdeploying_deadline_confirmed", step.NewState.ScenarioProfile.Reason); } [Fact] public void SelfDeploying_with_realmjoin_detected_defers_terminal_until_resolved() { - // SelfDeploying terminal handler observes the gate is closed → marks - // SelfDeployingDeferredCompletion on RealmJoinFacts, stays non-terminal, lets the - // RealmJoinResolved handler complete directly to Completed (not via Finalizing). + // Plan v9 (88a53223 defang): RJ-deferral moves from signal-time to deadline-fire-time. + // The DeviceSetupProvisioningComplete signal arms the deadline; the deadline-fired + // handler observes the RJ gate is closed and marks SelfDeployingDeferredCompletion. + // RealmJoinResolved then routes through CompleteIfDeferredOrBookkeep to terminal. var engine = new DecisionEngine(); var state = DecisionState.CreateInitial("rj-sd-2", "rj-tenant", T0); state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; @@ -225,22 +239,37 @@ namespace AutopilotMonitor.DecisionCore.Tests state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; - var deferred = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + // Signal arms deadline. Deferred flag NOT yet set (Plan v9: only at deadline-fire). + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + Assert.NotEqual(SessionStage.Completed, state.Stage); + Assert.Null(state.RealmJoinFacts.SelfDeployingDeferredCompletion); + + // Deadline fires. RJ-gate still closed → set deferred flag, NO terminal. + var deferredStep = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + var deferred = deferredStep.NewState; Assert.NotEqual(SessionStage.Completed, deferred.Stage); Assert.True(deferred.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value); - var step = engine.Reduce(deferred, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4), + // RealmJoinResolved releases the deferred SelfDeploying terminal. + var step = engine.Reduce(deferred, MakeSignal(5, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(10), new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); Assert.Equal(SessionStage.Completed, step.NewState.Stage); Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome); - // Direct Completed path emits enrollment_complete inline; no FinalizingGrace deadline - // needed because the SelfDeploying handler clears deadlines. + // Direct Completed path emits enrollment_complete; no FinalizingGrace deadline needed + // because the RJ-deferred branch clears deadlines. Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry && e.Parameters != null && e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + // Plan v9 F2: ScenarioProfile promoted to SelfDeploying/High in RJ-deferred-release. + Assert.Equal(EnrollmentMode.SelfDeploying, step.NewState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, step.NewState.ScenarioProfile.Confidence); + Assert.Equal("selfdeploying_deadline_confirmed", step.NewState.ScenarioProfile.Reason); + // RJ gate is open post-release (the WithResolved fact survived ClearSelfDeployingDeferred). + Assert.NotNull(step.NewState.RealmJoinFacts.ResolvedUtc); } // ============================================================== Per-package tracking @@ -284,8 +313,10 @@ namespace AutopilotMonitor.DecisionCore.Tests [Fact] public void Audit_trail_attaches_realmjoin_fields_when_enrollment_completes_after_resolved() { - // After SelfDeploying + RealmJoinResolved, the enrollment_complete effect must carry - // the realmjoin* audit-trail fields built by DecisionAuditTrailBuilder. + // After SelfDeploying-deadline-fired (deferred via RJ-gate) + RealmJoinResolved, the + // enrollment_complete effect must carry the realmjoin* audit-trail fields built by + // DecisionAuditTrailBuilder. Plan v9: signal arms deadline, deadline-fire defers when + // RJ gate closed, RJ-resolve releases via CompleteIfDeferredOrBookkeep. var engine = new DecisionEngine(); var state = DecisionState.CreateInitial("rj-audit", "rj-tenant", T0); state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; @@ -294,8 +325,12 @@ namespace AutopilotMonitor.DecisionCore.Tests state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + // Deadline fires at T+8 (signal at T+3 + 5min) → RJ gate closed → deferred. + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState; + Assert.True(state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value); - var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4), + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(10), new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); var completeEffect = Assert.Single(step.Effects, e => diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs index d4a917df..243d0334 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs @@ -101,11 +101,26 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.Equal(SessionStage.Completed, step.NewState.Stage); Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome); - var effect = Assert.Single(step.Effects); - Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, effect.Kind); - Assert.Equal("enrollment_complete", effect.Parameters!["eventType"]); - Assert.Equal("Succeeded", effect.Parameters["adminAction"]); - Assert.Equal("register_session_response", effect.Parameters["source"]); + // Plan v9 Phase 4 — UI phase coverage: AdminPreemption-Succeeded now prepends + // FinalizingSetup + Complete phase declarations before the inline enrollment_complete + // so the Web timeline opens both phase bars (parity with Classic + SelfDeploying paths). + Assert.Equal(3, step.Effects.Count); + + var finalizingEffect = step.Effects[0]; + Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, finalizingEffect.Kind); + Assert.Equal("phase_transition", finalizingEffect.Parameters!["eventType"]); + Assert.Equal("FinalizingSetup", finalizingEffect.Parameters["phase"]); + + var completePhaseEffect = step.Effects[1]; + Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, completePhaseEffect.Kind); + Assert.Equal("phase_transition", completePhaseEffect.Parameters!["eventType"]); + Assert.Equal("Complete", completePhaseEffect.Parameters["phase"]); + + var enrollmentCompleteEffect = step.Effects[2]; + Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, enrollmentCompleteEffect.Kind); + Assert.Equal("enrollment_complete", enrollmentCompleteEffect.Parameters!["eventType"]); + Assert.Equal("Succeeded", enrollmentCompleteEffect.Parameters["adminAction"]); + Assert.Equal("register_session_response", enrollmentCompleteEffect.Parameters["source"]); } [Fact] diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs index 1e7bafb2..ac4b9d58 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs @@ -161,16 +161,23 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios Assert.Equal(SessionStage.Completed, finalizingFire.ToStage); // The DeviceSetupProvisioningComplete signal is observed as a taken but stage-unchanged - // step (informational on Classic path — not a terminal trigger). + // step (informational on Classic path — not a terminal trigger). Plan v9: handler tags + // its trigger with ":AccountSetupAlreadyEntered" to discriminate from the other paths. var provTransition = Assert.Single( result.Transitions, - t => t.Trigger == nameof(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind.DeviceSetupProvisioningComplete)); + t => t.Trigger.StartsWith(nameof(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind.DeviceSetupProvisioningComplete), System.StringComparison.Ordinal)); Assert.True(provTransition.Taken); Assert.Equal(SessionStage.EspAccountSetup, provTransition.FromStage); Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage); + Assert.EndsWith(":AccountSetupAlreadyEntered", provTransition.Trigger); // DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path. Assert.Equal(HypothesisLevel.Unknown, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level); + + // Plan v9: the DeviceSetupResolvedUtc anchor is set UNCONDITIONALLY (even on the + // Classic-already-AccountSetup short-circuit) so the audit trail surfaces that the + // signal was observed. + Assert.NotNull(result.FinalState.DeviceSetupResolvedUtc); } } } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs index ab73805b..a9b1a465 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs @@ -34,31 +34,42 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios DecisionEngine.DeviceOnlyReasons.DeviceOnly, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason); - // Profile upgraded to Mode=SelfDeploying @ High by the SelfDeploying handler. + // Plan v9 — terminal classification now happens at DeadlineFired, not at signal-time. + // Reason changed from "selfdeploying_provisioning_complete" to "selfdeploying_deadline_confirmed". Assert.Equal(EnrollmentMode.SelfDeploying, result.FinalState.ScenarioProfile.Mode); Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence); - Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.ScenarioProfile.Reason); + Assert.Equal("selfdeploying_deadline_confirmed", result.FinalState.ScenarioProfile.Reason); - Assert.Equal(3, result.Transitions.Count); + // Fixture now drives 4 signals (SessionStarted, EspPhaseChanged(DeviceSetup), + // DeviceSetupProvisioningComplete, DeadlineFired) instead of 3. + Assert.Equal(4, result.Transitions.Count); Assert.All(result.Transitions, t => Assert.True(t.Taken)); } [Fact] - public void SelfDeployingHappy_schedulesAndCancelsDeviceOnlyEspDetection() + public void SelfDeployingHappy_armsAndClearsDeviceOnlyEspDetectionViaDeadline() { + // Plan v9 (88a53223 defang) — semantics: + // - DeviceSetupProvisioningComplete arms the deadline (no longer terminal at signal-time). + // - DeadlineFired completes the session (after all guards pass) and clears deadlines. + // Final state still has no active deadlines, just via a different mechanism. var result = RunFixture( fixtureFilename: "selfdeploying-happy-v1.jsonl", sessionId: "session-anon-0004", tenantId: "tenant-anon-0004"); - // Replay intermediate check via harness is not exposed, but we can assert the - // final state has no active deadlines (the provisioning-complete handler clears them). Assert.Empty(result.FinalState.Deadlines); + // Anchor must be set so downstream stale-fire guards can distinguish "new path" from + // "rollout-race deadline from old code". + Assert.NotNull(result.FinalState.DeviceSetupResolvedUtc); } [Fact] - public void DeviceOnlyEspExitUnknown_deadlineFires_then_provisioningCompletes() + public void DeviceOnlyEspExitUnknown_provisioningCompletesArmsDeadline_thenFiresToTerminal() { + // Plan v9 semantics: DeviceSetupProvisioningComplete arms the deadline; DeadlineFired + // is the sole SelfDeploying-terminal entry. The same fixture (now without the legacy + // arm-at-DeviceSetup-start DeadlineFired) drives the same final state via the new path. var result = RunFixture( fixtureFilename: "selfdeploying-esp-exit-unknown-v1.jsonl", sessionId: "session-anon-0005", @@ -67,20 +78,23 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios Assert.Equal(SessionStage.Completed, result.FinalState.Stage); Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); - // DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning). + // DeviceOnlyDeployment is now set to Confirmed exclusively in the deadline-fired terminal + // branch (previously the v1 path set it twice — first at signal-time, then again at + // deadline-fire). Final state is identical: Confirmed/DeviceOnly. Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level); Assert.Equal( DecisionEngine.DeviceOnlyReasons.DeviceOnly, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason); - // The deadline transition is present and taken (hypothesis update without stage change). + // The DeadlineFired transition is the terminal one (Stage transitions to Completed + // here, NOT a "hypothesis-only" no-op like in the v1 semantics). var deadlineTransition = Assert.Single( result.Transitions, t => t.Trigger == $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); Assert.True(deadlineTransition.Taken); - Assert.Equal(deadlineTransition.FromStage, deadlineTransition.ToStage); - Assert.Equal(SessionStage.EspDeviceSetup, deadlineTransition.ToStage); + Assert.Equal(SessionStage.Completed, deadlineTransition.ToStage); + // Fixture: SessionStarted + EspPhaseChanged + DeviceSetupProvisioningComplete + DeadlineFired = 4. Assert.Equal(4, result.Transitions.Count); } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs new file mode 100644 index 00000000..507cd8cd --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs @@ -0,0 +1,520 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Targeted coverage for the SelfDeploying-defang refactor (Plan v9, session + /// 88a53223-9795-4188-8352-7df9f0af9bb7). Validates that: + /// + /// is no longer + /// terminal — it only anchors + /// and arms the 5-min deadline. + /// The deadline-fired handler is the sole SelfDeploying-terminal entry point and + /// re-checks all guards (stale-fire / Stage-terminal / AccountSetup / monotonic mode) + /// before classifying. + /// The RJ-deferred release path (CompleteIfDeferredOrBookkeep) re-checks the same + /// guards and demotes back to Classic flow when post-deferral signals arrive. + /// Race conditions (DueAtUtc mismatch, rollout snapshots) are handled defensively. + /// + /// + public sealed class SelfDeployingDeadlineTerminalTests + { + private static readonly DateTime T0 = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc); + + private static DecisionSignal MakeSignal( + long ordinal, + DecisionSignalKind kind, + DateTime occurredAtUtc, + IReadOnlyDictionary? payload = null) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"), + payload: payload); + + private static DecisionState PrimeDeviceSetup(DecisionEngine engine, DecisionState initialState) + { + var state = engine.Reduce(initialState, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + return state; + } + + // ============================================================== #1 Happy path + + [Fact] + public void HappyPath_signalArmsDeadline_deadlineFiresTerminal() + { + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-1", "t", T0)); + + // Signal arms deadline, NO terminal. + var signalStep = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))); + Assert.Equal(SessionStage.EspDeviceSetup, signalStep.NewState.Stage); + Assert.NotNull(signalStep.NewState.DeviceSetupResolvedUtc); + var deadline = Assert.Single(signalStep.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(T0.AddMinutes(8), deadline.DueAtUtc); + + // Deadline fires at DueAtUtc → terminal SelfDeploying with phase declarations. + var terminalStep = engine.Reduce(signalStep.NewState, MakeSignal(3, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.Equal(SessionStage.Completed, terminalStep.NewState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, terminalStep.NewState.Outcome); + Assert.Equal(EnrollmentMode.SelfDeploying, terminalStep.NewState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, terminalStep.NewState.ScenarioProfile.Confidence); + Assert.Equal("selfdeploying_deadline_confirmed", terminalStep.NewState.ScenarioProfile.Reason); + + // Effects sequence: phase_transition(FinalizingSetup) + phase_transition(Complete) + enrollment_complete + Assert.Equal(3, terminalStep.Effects.Count); + var finalizingParams = terminalStep.Effects[0].Parameters!; + Assert.Equal("phase_transition", finalizingParams["eventType"]); + Assert.Equal("FinalizingSetup", finalizingParams["phase"]); + var completeParams = terminalStep.Effects[1].Parameters!; + Assert.Equal("phase_transition", completeParams["eventType"]); + Assert.Equal("Complete", completeParams["phase"]); + Assert.Equal("enrollment_complete", terminalStep.Effects[2].Parameters!["eventType"]); + } + + // ============================================================== #2 Race guard: Stage already terminal + + [Fact] + public void RaceGuardA_stageAlreadyTerminal_deadEndsWithoutDoubleComplete() + { + // Scenario: signal arms deadline, then Classic completes via Hello+Desktop+Finalizing + // grace fire BEFORE the SelfDeploying deadline fires. Stage is already Completed when + // the (now-stale) DeviceOnlyEspDetection deadline fires. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-2", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + // Simulate Classic completion: AccountSetup → EspExiting → HelloResolved + DesktopArrived → FinalizingGrace fires. + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(4), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(5))).NewState; + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.EspExiting, T0.AddMinutes(6))).NewState; + state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.HelloResolved, T0.AddMinutes(7), + new Dictionary { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState; + state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DesktopArrived, T0.AddMinutes(7))).NewState; + var finalizingGrace = state.Deadlines.Single(d => d.Name == DeadlineNames.FinalizingGrace); + state = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, finalizingGrace.DueAtUtc, + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace })).NewState; + Assert.Equal(SessionStage.Completed, state.Stage); + + // The DeviceOnlyEspDetection deadline was cancelled by the EspPhaseChanged→AccountSetup + // path long ago, but let's simulate a stale DeadlineFired that races through anyway — + // either guard A (Stage.IsTerminal) or guard B (deadline not armed) catches it. + var step = engine.Reduce(state, MakeSignal(9, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.False(step.Transition.Taken); + // The engine's after-terminal dispatch intercepts the signal before our deadline + // handler even runs (DispatchSignalAfterTerminal sets DeadEndReason="signal_after_terminal:Completed"). + // Either way, the post-state must remain terminal-Completed with no spurious effects. + Assert.Contains("signal_after_terminal", step.Transition.DeadEndReason!, StringComparison.Ordinal); + Assert.Empty(step.Effects); + Assert.Equal(SessionStage.Completed, step.NewState.Stage); + } + + // ============================================================== #3 Race guard: AccountSetup entered + + [Fact] + public void RaceGuardB_accountSetupEnteredBetweenArmAndFire_deadEnds() + { + // Defensive case: AccountSetup-cancel-deadline path normally prevents this, but + // simulate the race via a manually-constructed state. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-3", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + // Force AccountSetupEnteredUtc set + keep deadline armed via manual builder mutation. + var builder = state.ToBuilder(); + builder.AccountSetupEnteredUtc = new SignalFact(T0.AddMinutes(4), 99); + state = builder.Build(); + Assert.Contains(state.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + + var deadline = state.Deadlines.Single(d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + var step = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeadlineFired, deadline.DueAtUtc, + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.False(step.Transition.Taken); + Assert.Equal("device_only_esp_detection_account_setup_entered", step.Transition.DeadEndReason); + Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage); + // CRITICAL: the past-due deadline MUST be cancelled state-side, otherwise a snapshot + // reload would have the scheduler re-arm it → repeat dead-end loop. + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + } + + // ============================================================== #5 RJ closed at signal, resolved before deadline + + [Fact] + public void RJClosedAtSignal_resolvedBeforeDeadline_doesNotPrematurelyTerminate() + { + // Plan v9 F1 verification: under v1 code, DeviceSetupProvisioningComplete with RJ-gate + // closed set SelfDeployingDeferredCompletion at signal-time. Then if RJ resolved before + // 5min, the RJ-resolve handler would complete the session — premature termination. + // The defang moves deferral to deadline-fire-time; RJ resolving before the deadline + // (without the deferred flag set) does NOT complete the session. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-5", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + + // Signal arrives with RJ-gate closed → arms deadline, does NOT set deferred flag. + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + Assert.Null(state.RealmJoinFacts.SelfDeployingDeferredCompletion); + + // RJ resolves at T+4min (well before deadline at T+8min). Without the deferred flag, + // CompleteIfDeferredOrBookkeep takes the bookkeeping path — Stage stays non-terminal. + var rjResolveStep = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); + Assert.NotEqual(SessionStage.Completed, rjResolveStep.NewState.Stage); + Assert.DoesNotContain(rjResolveStep.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + + // The deadline still fires at T+8min and now completes (gate open, no AccountSetup). + var deadlineStep = engine.Reduce(rjResolveStep.NewState, MakeSignal(5, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + Assert.Equal(SessionStage.Completed, deadlineStep.NewState.Stage); + } + + // ============================================================== #6 Replay idempotency + + [Fact] + public void Replay_signalTwice_anchorSetOnce_deadlineNotReArmed() + { + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-6", "t", T0)); + + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + var anchorOrdinal1 = state.DeviceSetupResolvedUtc!.SourceSignalOrdinal; + var firstDueAt = state.Deadlines.Single(d => d.Name == DeadlineNames.DeviceOnlyEspDetection).DueAtUtc; + + // Replay the signal (different ordinal, same kind). Anchor must stay at first ordinal, + // deadline DueAtUtc must NOT advance — second signal is a no-op passthrough. + var step = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(4))); + + Assert.Equal(anchorOrdinal1, step.NewState.DeviceSetupResolvedUtc!.SourceSignalOrdinal); + Assert.Equal(firstDueAt, step.NewState.Deadlines.Single(d => d.Name == DeadlineNames.DeviceOnlyEspDetection).DueAtUtc); + // Transition is taken (LastAppliedSignalOrdinal advanced) but tagged with AnchorAlreadySet. + Assert.EndsWith(":AnchorAlreadySet", step.Transition.Trigger); + Assert.Empty(step.Effects); + } + + // ============================================================== #8 Snapshot-migration A: signal arrives first + + [Fact] + public void SnapshotMigration_oldStyleDeadlineArmed_signalReplacesIt() + { + // Pre-load state with an OLD-style DeviceOnlyEspDetection deadline (armed at + // DeviceSetup-START by legacy code) and no DeviceSetupResolvedUtc anchor. The new + // signal handler must cancel the old deadline and arm a fresh one from the signal time. + var engine = new DecisionEngine(); + var oldDeadline = new ActiveDeadline( + name: DeadlineNames.DeviceOnlyEspDetection, + dueAtUtc: T0.AddMinutes(6), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection }); + var legacyState = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-8", "t", T0)) + .ToBuilder() + .AddDeadline(oldDeadline) + .Build(); + + var step = engine.Reduce(legacyState, MakeSignal(99, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(10))); + + Assert.NotNull(step.NewState.DeviceSetupResolvedUtc); + var newDeadline = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(T0.AddMinutes(15), newDeadline.DueAtUtc); + // Effects include CancelDeadline (for the old one) and ScheduleDeadline (for the new one). + Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline); + Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline); + } + + // ============================================================== #9 Stale-fire guard A: no anchor + + [Fact] + public void StaleFireGuardA_noAnchor_deadEnds_andCancelsDeadlineStateSide() + { + // Pre-load state with the deadline armed but DeviceSetupResolvedUtc=null (rollout race: + // old code armed deadline at DeviceSetup-START, new code fires under post-upgrade). + // The guard must dead-end AND cancel the deadline state-side (otherwise snapshot reload + // re-arms it via the scheduler → infinite loop). + var engine = new DecisionEngine(); + var oldDeadline = new ActiveDeadline( + name: DeadlineNames.DeviceOnlyEspDetection, + dueAtUtc: T0.AddMinutes(6), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection }); + var rolloutState = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-9", "t", T0)) + .ToBuilder() + .AddDeadline(oldDeadline) + .Build(); + Assert.Null(rolloutState.DeviceSetupResolvedUtc); + + var step = engine.Reduce(rolloutState, MakeSignal(99, DecisionSignalKind.DeadlineFired, T0.AddMinutes(6), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.False(step.Transition.Taken); + Assert.Equal("device_only_esp_detection_stale_no_anchor", step.Transition.DeadEndReason); + Assert.Empty(step.Effects); + // Critical: deadline removed from state so snapshot reload won't re-arm it. + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + } + + // ============================================================== #10 Stale-fire guard B: cancelled deadline + + [Fact] + public void StaleFireGuardB_deadlineCancelled_deadEnds() + { + // State has DeviceSetupResolvedUtc set, deadline was cancelled (e.g. by AccountSetup), + // but a stale DeadlineFired raced through. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-10", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(4), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + Assert.DoesNotContain(state.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + + // Stale fire arrives anyway. + var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.False(step.Transition.Taken); + Assert.Equal("device_only_esp_detection_stale_deadline_not_armed", step.Transition.DeadEndReason); + Assert.Empty(step.Effects); + } + + // ============================================================== #11 Stale-fire guard C: DueAtUtc mismatch + + [Fact] + public void StaleFireGuardC_dueAtUtcMismatch_oldFireAfterCancelThenRearm_keepsNewDeadline() + { + // Cancel-then-rearm race: old deadline (DueAt=T+8) fires queued; before processing, + // DeviceSetupProvisioningComplete arrives (signal at T+10) → cancel+rearm to DueAt=T+15. + // The queued OLD fire (OccurredAtUtc=T+8) arrives. Guard C must dead-end WITHOUT + // cancelling the new deadline. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-11", "t", T0)); + + // First signal at T+3 arms deadline at T+8. + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + var firstDueAt = state.Deadlines.Single(d => d.Name == DeadlineNames.DeviceOnlyEspDetection).DueAtUtc; + Assert.Equal(T0.AddMinutes(8), firstDueAt); + + // Manually simulate the cancel-then-rearm: clear DeviceSetupResolvedUtc + deadline, + // then re-feed a second DeviceSetupProvisioningComplete with a later timestamp. + // (In production the first signal's anchor wouldn't clear, but here we want to test + // the DueAtUtc-mismatch guard specifically — so we force a second arm via builder.) + var rearmedBuilder = state.ToBuilder(); + rearmedBuilder.DeviceSetupResolvedUtc = new SignalFact(T0.AddMinutes(10), 5); + rearmedBuilder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + rearmedBuilder.AddDeadline(new ActiveDeadline( + name: DeadlineNames.DeviceOnlyEspDetection, + dueAtUtc: T0.AddMinutes(15), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + state = rearmedBuilder.Build(); + + // OLD queued fire arrives with OccurredAtUtc=T+8 — mismatches new deadline at T+15. + var step = engine.Reduce(state, MakeSignal(99, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.False(step.Transition.Taken); + Assert.Equal("device_only_esp_detection_stale_due_at_mismatch", step.Transition.DeadEndReason); + Assert.Empty(step.Effects); + // CRITICAL: the new deadline (T+15) must STILL be in state — guard C must NOT cancel it. + var stillArmed = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(T0.AddMinutes(15), stillArmed.DueAtUtc); + + // Subsequent real fire at the new DueAt completes terminal. + var realFireStep = engine.Reduce(step.NewState, MakeSignal(100, DecisionSignalKind.DeadlineFired, T0.AddMinutes(15), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + Assert.Equal(SessionStage.Completed, realFireStep.NewState.Stage); + } + + // ============================================================== #13 Audit-trail census + + [Fact] + public void AuditTrail_includesDeviceSetupProvisioningComplete_inSignalsSeen() + { + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-13", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + var step = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + // Find the enrollment_complete effect; its typedPayload (built by DecisionAuditTrailBuilder) + // must include device_setup_provisioning_complete in signalsSeen. + var completeEffect = Assert.Single(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + Assert.NotNull(completeEffect.TypedPayload); + var data = Assert.IsType>(completeEffect.TypedPayload); + + var signalsSeen = Assert.IsType>(data["signalsSeen"]); + Assert.Contains("device_setup_provisioning_complete", signalsSeen); + var timestamps = Assert.IsType>(data["signalTimestamps"]); + Assert.True(timestamps.ContainsKey("deviceSetupResolved")); + } + + // ============================================================== #14 Monotonic mode conflict + + [Fact] + public void RaceGuardC_monotonicModeConflict_ClassicHigh_deadEndsWithoutRelabel() + { + // Pre-set ScenarioProfile.Mode=Classic/High (as if ApplyImeUserSessionCompleted had run) + // and feed the DeviceOnlyEspDetection deadline. The guard must dead-end without + // relabeling Mode to SelfDeploying. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-14", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + var classicProfile = state.ScenarioProfile.With( + mode: EnrollmentMode.Classic, + confidence: ProfileConfidence.High, + reason: "ime_user_session_completed"); + state = state.ToBuilder().WithScenarioProfile(classicProfile).Build(); + + var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + + Assert.False(step.Transition.Taken); + Assert.Equal("device_only_esp_detection_monotonic_mode_conflict", step.Transition.DeadEndReason); + Assert.Empty(step.Effects); + // Mode must remain Classic/High. + Assert.Equal(EnrollmentMode.Classic, step.NewState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, step.NewState.ScenarioProfile.Confidence); + // CRITICAL: the past-due deadline MUST be cancelled state-side, otherwise a snapshot + // reload would have the scheduler re-arm it → repeat dead-end loop. + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + } + + // ============================================================== #15 RJ-deferred release race: AccountSetup arrives + + [Fact] + public void RJDeferredRelease_accountSetupArrivedAfterDeferral_demotesToClassicFlow() + { + // Sequence: signal arms deadline → RJ-detected (gate closed) → deadline fires + // (deferred, DeviceOnly=Confirmed) → AccountSetup arrives later → RJ resolves. + // The deferred-release re-check guard must bail out, clear deferred flag, reset + // DeviceOnly hypothesis, and NOT terminate as SelfDeploying. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-15", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + // Deadline fires with RJ-gate closed → deferred + DeviceOnly Confirmed. + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState; + Assert.True(state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value); + Assert.Equal(HypothesisLevel.Confirmed, state.ClassifierOutcomes.DeviceOnlyDeployment.Level); + + // AccountSetup arrives unexpectedly (e.g. Windows delayed ESP transition). + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(9), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + Assert.NotNull(state.AccountSetupEnteredUtc); + + // RJ resolves — the deferred-release branch must re-check and bail. + var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(10), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); + + // (1) No premature SelfDeploying terminal. + Assert.NotEqual(SessionStage.Completed, step.NewState.Stage); + Assert.DoesNotContain(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + // (2) ScenarioProfile.Mode NOT relabeled to SelfDeploying. + Assert.NotEqual(EnrollmentMode.SelfDeploying, step.NewState.ScenarioProfile.Mode); + // (3) DeviceOnly hypothesis reset to Unknown (prevents WhiteGlove-classifier false positive). + Assert.Equal(HypothesisLevel.Unknown, step.NewState.ClassifierOutcomes.DeviceOnlyDeployment.Level); + // (4) RJ gate is open post-release — the WithResolved fact survived ClearSelfDeployingDeferred. + Assert.NotNull(step.NewState.RealmJoinFacts.ResolvedUtc); + // (5) Deferred flag cleared. + Assert.Null(step.NewState.RealmJoinFacts.SelfDeployingDeferredCompletion); + } + + // ============================================================== #16 RJ-deferred release race: monotonic conflict + + [Fact] + public void RJDeferredRelease_classicHighAfterDeferral_demotesToClassicFlow() + { + // Variant of #15: instead of AccountSetup arriving, Classic/High Mode gets set + // (simulating ApplyImeUserSessionCompleted or similar). + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-16", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState; + Assert.True(state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value); + + // Promote profile to Classic/High via builder (simulates ApplyImeUserSessionCompleted). + state = state.ToBuilder() + .WithScenarioProfile(state.ScenarioProfile.With( + mode: EnrollmentMode.Classic, + confidence: ProfileConfidence.High, + reason: "ime_user_session_completed")) + .Build(); + + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(10), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); + + Assert.NotEqual(SessionStage.Completed, step.NewState.Stage); + // Mode stays Classic/High. + Assert.Equal(EnrollmentMode.Classic, step.NewState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, step.NewState.ScenarioProfile.Confidence); + // DeviceOnly hypothesis reset. + Assert.Equal(HypothesisLevel.Unknown, step.NewState.ClassifierOutcomes.DeviceOnlyDeployment.Level); + // RJ gate open. + Assert.NotNull(step.NewState.RealmJoinFacts.ResolvedUtc); + Assert.Null(step.NewState.RealmJoinFacts.SelfDeployingDeferredCompletion); + } + + // ============================================================== #16b RJ-deferred release race: Timeout variant + + [Fact] + public void RJDeferredRelease_timeoutVariant_withAccountSetup_demotesAndPreservesTimeoutFact() + { + // Same as #15 but RJ never resolves — RealmJoinTimeout deadline fires. + // Asserts the RJ-gate-open and Outcome=Timeout invariants hold post-bailout (the + // Plan v9 F1 regression would have discarded WithTimeoutOutcome). + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-16b", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })).NewState; + // AccountSetup arrives unexpectedly post-deferral. + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(9), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + + // RJ timeout deadline fires — release path runs WithTimeoutOutcome into builder, then + // CompleteIfDeferredOrBookkeep re-check bails out. + var rjTimeout = state.Deadlines.Single(d => d.Name == DeadlineNames.RealmJoinTimeout); + var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DeadlineFired, rjTimeout.DueAtUtc, + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })); + + // No SelfDeploying terminal. + Assert.NotEqual(EnrollmentMode.SelfDeploying, step.NewState.ScenarioProfile.Mode); + // CRITICAL (v9 F1 regression test): RJ Outcome=Timeout must SURVIVE the bailout. + Assert.Equal(RealmJoinFacts.OutcomeTimeout, step.NewState.RealmJoinFacts.Outcome!.Value); + // Deferred flag cleared, DeviceOnly hypothesis reset. + Assert.Null(step.NewState.RealmJoinFacts.SelfDeployingDeferredCompletion); + Assert.Equal(HypothesisLevel.Unknown, step.NewState.ClassifierOutcomes.DeviceOnlyDeployment.Level); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs index ba6e679d..8b6669d7 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs @@ -322,6 +322,66 @@ namespace AutopilotMonitor.DecisionCore.Tests JsonConvert.DeserializeObject("\"Unverified\"", settings)); } + [Fact] + public void Deserialize_v3Snapshot_withoutDeviceSetupResolvedUtc_field_yieldsNullAnchor() + { + // Rollout-safety regression (Plan v9, 88a53223 SelfDeploying defang): the new + // DeviceSetupResolvedUtc SignalFact is an additive nullable field. Old persisted + // snapshots from the v3 schema (DecisionState.CurrentSchemaVersion was "v3" before + // 2026-05-21) DO NOT contain this property in their JSON. Loading such a snapshot + // under v4 code must succeed and produce DeviceSetupResolvedUtc == null — otherwise + // any session in flight at the time of the agent upgrade would fail to rehydrate. + // + // The JSON below is a minimal v3-shaped DecisionState payload constructed by hand + // (NOT round-tripped through StateSerializer.Serialize, which would include the new + // field). It mirrors what an actual on-disk v3 snapshot.json would look like. + const string v3Json = @"{ + ""SessionId"": ""legacy-session"", + ""TenantId"": ""legacy-tenant"", + ""Stage"": ""EspDeviceSetup"", + ""Outcome"": null, + ""CurrentEnrollmentPhase"": null, + ""DeviceSetupEnteredUtc"": null, + ""AccountSetupEnteredUtc"": null, + ""FinalizingEnteredUtc"": null, + ""AccountSetupProvisioningSucceededUtc"": null, + ""EspFinalExitUtc"": null, + ""DesktopArrivedUtc"": null, + ""HelloResolvedUtc"": null, + ""SystemRebootUtc"": null, + ""HelloOutcome"": null, + ""ImeMatchedPatternId"": null, + ""Deadlines"": [], + ""LastAppliedSignalOrdinal"": 5, + ""StepIndex"": 6, + ""AppInstallFacts"": null, + ""ScenarioProfile"": null, + ""ScenarioObservations"": null, + ""ClassifierOutcomes"": null, + ""HelloPolicyEnabled"": null, + ""AgentBootUtc"": ""2026-05-20T09:00:00Z"", + ""LastFailureTrigger"": null, + ""RealmJoinFacts"": null, + ""SchemaVersion"": ""v3"" + }"; + + var deserialized = StateSerializer.Deserialize(v3Json); + + // Critical: the missing DeviceSetupResolvedUtc field deserializes to null (the optional + // ctor parameter default) — no ArgumentException, no JsonSerializationException. + Assert.Null(deserialized.DeviceSetupResolvedUtc); + + // The rest of the state survived the load — non-default fields are preserved. + Assert.Equal("legacy-session", deserialized.SessionId); + Assert.Equal("legacy-tenant", deserialized.TenantId); + Assert.Equal(SessionStage.EspDeviceSetup, deserialized.Stage); + Assert.Equal(5L, deserialized.LastAppliedSignalOrdinal); + Assert.Equal(6, deserialized.StepIndex); + // SchemaVersion is preserved verbatim (the reducer doesn't auto-upgrade v3 → v4 on read; + // the next state mutation re-builds via DecisionState ctor and gets the current default). + Assert.Equal("v3", deserialized.SchemaVersion); + } + [Fact] public void ScenarioProfile_stateRoundtripsThroughStateSerializer_enumsAsStrings() { diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs index 70cf8a48..b4265d3b 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs @@ -306,17 +306,69 @@ namespace AutopilotMonitor.DecisionCore.Tests.State Assert.Same(seed, profile); } - [Fact] - public void DeviceSetupProvisioningComplete_upgradesToSelfDeployingHigh() - { - var signal = MakeSignal(DecisionSignalKind.DeviceSetupProvisioningComplete, ordinal: 6, payload: null); + // ================================================================== ApplySelfDeployingDeadlineConfirmed + // Plan v9 — ApplyDeviceSetupProvisioningComplete was removed (signal-time Mode promotion + // caused false-positive SelfDeploying terminations on Classic flows, session 88a53223). + // ApplySelfDeployingDeadlineConfirmed is its replacement, invoked from the deadline-fired + // handler after all guards have passed. Same monotonic semantics as ApplyImeUserSessionCompleted. - var profile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete( + [Fact] + public void SelfDeployingDeadlineConfirmed_fromUnknown_upgradesToSelfDeployingHigh() + { + var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null); + + var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed( EnrollmentScenarioProfile.Empty, signal); Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode); Assert.Equal(ProfileConfidence.High, profile.Confidence); - Assert.Equal("selfdeploying_provisioning_complete", profile.Reason); + Assert.Equal("selfdeploying_deadline_confirmed", profile.Reason); + } + + [Fact] + public void SelfDeployingDeadlineConfirmed_idempotent_whenAlreadySelfDeployingHigh() + { + var seed = EnrollmentScenarioProfile.Empty.With( + mode: EnrollmentMode.SelfDeploying, + confidence: ProfileConfidence.High, + reason: "selfdeploying_deadline_confirmed", + evidenceOrdinal: 5); + var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null); + + var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed(seed, signal); + + Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode); + Assert.Equal(ProfileConfidence.High, profile.Confidence); + Assert.Equal("selfdeploying_deadline_confirmed", profile.Reason); + } + + [Fact] + public void SelfDeployingDeadlineConfirmed_monotonicBail_whenClassicAlreadyHigh() + { + var seed = EnrollmentScenarioProfile.Empty.With( + mode: EnrollmentMode.Classic, + confidence: ProfileConfidence.High, + reason: "ime_user_session_completed"); + var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null); + + var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed(seed, signal); + + // Monotonic: SelfDeploying must not overwrite a Classic/High classification. + Assert.Same(seed, profile); + } + + [Fact] + public void SelfDeployingDeadlineConfirmed_monotonicBail_whenWhiteGloveAlreadyHigh() + { + var seed = EnrollmentScenarioProfile.Empty.With( + mode: EnrollmentMode.WhiteGlove, + confidence: ProfileConfidence.High, + reason: "classifier_whiteglove_sealing_confirmed"); + var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null); + + var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed(seed, signal); + + Assert.Same(seed, profile); } [Fact] diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 5433a338..f86f1cfe 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -82,21 +82,17 @@ namespace AutopilotMonitor.DecisionCore.Engine builder.ScenarioProfile, signal); } - // Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup. - // See DecisionEngine.SelfDeploying.cs for the deadline-fired handler. - // EffectiveDeadlineBase floors the 5-min window at AgentBootUtc so a replayed - // DeviceSetup signal from an older CMTrace log entry doesn't collapse the deadline - // to immediate-fire — that would mark a perfectly normal Classic enrollment as - // device-only at boot (premature classifier promotion to DeviceOnly@Strong). + // Device-Only ESP detection (plan §2.7 + 88a53223 defang): the DeviceOnlyEspDetection + // deadline is now armed by HandleDeviceSetupProvisioningCompleteV1 (DeviceSetup-END), + // not here at DeviceSetup-START. Rationale: DeviceSetup with apps takes 10-20min, so + // a 5-min deadline armed at start fired mid-enrollment while DeviceSetup was still in + // progress — semantically meaningless. The defang moves the arm to "5min after + // DeviceSetup-resolved" so the "no AccountSetup after DeviceSetup-done" check actually + // decides something. The AccountSetup-cancel below is unchanged — it's a no-op when + // the deadline isn't armed (fast DeviceSetup, AccountSetup arrives before signal) and + // a genuine cancel when it is (the normal Classic flow during the 5min window). var effectsList = new List(); - if (enrollmentPhase == EnrollmentPhase.DeviceSetup && - state.DeviceSetupEnteredUtc == null) - { - var devOnlyDl = BuildDeviceOnlyEspDetectionDeadline(EffectiveDeadlineBase(state, signal)); - builder.AddDeadline(devOnlyDl); - effectsList.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: devOnlyDl)); - } - else if (enrollmentPhase == EnrollmentPhase.AccountSetup) + if (enrollmentPhase == EnrollmentPhase.AccountSetup) { builder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); effectsList.Add(new DecisionEffect( @@ -639,7 +635,15 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: $"DeadlineFired:{DeadlineNames.FinalizingGrace}"); - var effects = new[] { BuildEnrollmentCompleteEffect(newState, $"DeadlineFired:{DeadlineNames.FinalizingGrace}") }; + // Plan v9 Phase 4 — UI phase coverage: emit phase_transition(Complete) before + // enrollment_complete so the Web timeline opens the Complete phase bar. The prior + // phase_transition(FinalizingSetup) was already emitted by TransitionToFinalizing + // (Classic.cs:609) when this deadline was armed. + var effects = new[] + { + BuildPhaseTransitionEffect(EnrollmentPhase.Complete), + BuildEnrollmentCompleteEffect(newState, $"DeadlineFired:{DeadlineNames.FinalizingGrace}"), + }; return new DecisionStep(newState, transition, effects); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index 45eef968..b0a105f1 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -344,23 +344,63 @@ namespace AutopilotMonitor.DecisionCore.Engine if (selfDeployingDeferred) { - preparedBuilder - .WithStage(SessionStage.Completed) - .WithOutcome(SessionOutcome.EnrollmentComplete) - .ClearDeadlines(); - var completedState = preparedBuilder.Build(); - var completedTransition = BuildTakenTransition( - before: state, - signal: signal, - toStage: SessionStage.Completed, - nextStepIndex: nextStepIndex, - trigger: trigger + ":SelfDeployingDeferred"); + // Plan v9 re-check guards: between the SelfDeploying-deadline-fire (which set + // SelfDeployingDeferredCompletion) and now (RJ-resolve / RJ-timeout), the world + // may have moved — AccountSetup may have arrived or a stronger Mode classification + // (Classic/High, WhiteGlove/High) may have been set. In those cases the deferred + // SelfDeploying terminal is no longer appropriate: clear the deferred flag, reset + // the DeviceOnly hypothesis (would otherwise corrupt the WhiteGlove classifier), + // and fall through to classicReady / bookkeeping evaluation. + var monotonicModeConflict = + state.ScenarioProfile.Confidence == ProfileConfidence.High + && state.ScenarioProfile.Mode != EnrollmentMode.Unknown + && state.ScenarioProfile.Mode != EnrollmentMode.SelfDeploying; + var accountSetupEntered = state.AccountSetupEnteredUtc != null; - var effects = new List(capacity: 2); - if (leadingEffect != null) effects.Add(leadingEffect); - effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred")); + if (accountSetupEntered || monotonicModeConflict) + { + // Clear on preparedBuilder.* (not state.*) — the caller has already written + // WithResolved(...) / WithTimeoutOutcome(...) into preparedBuilder, and using + // state.RealmJoinFacts as the base would discard that and leave + // RealmJoinGateOpen(postState) == false → session stuck (Plan v9 F1). + preparedBuilder.RealmJoinFacts = preparedBuilder.RealmJoinFacts.ClearSelfDeployingDeferred(); + preparedBuilder.ClassifierOutcomes = preparedBuilder.ClassifierOutcomes.WithDeviceOnlyDeployment( + Hypothesis.UnknownInstance); + selfDeployingDeferred = false; + // fall through to classicReady / bookkeeping below + } + else + { + // Promote ScenarioProfile to SelfDeploying/High via the monotonic-respecting + // updater (Plan v9 F2 — keeps state↔wire consistent; without this the + // enrollment_complete event would be emitted while the snapshot still showed + // ScenarioProfile.Mode=Unknown). + preparedBuilder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed( + preparedBuilder.ScenarioProfile, signal); - return new DecisionStep(completedState, completedTransition, effects.ToArray()); + preparedBuilder + .WithStage(SessionStage.Completed) + .WithOutcome(SessionOutcome.EnrollmentComplete) + .ClearDeadlines(); + var completedState = preparedBuilder.Build(); + var completedTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Completed, + nextStepIndex: nextStepIndex, + trigger: trigger + ":SelfDeployingDeferred"); + + // Plan v9 Phase 4 — UI phase coverage: emit FinalizingSetup + Complete phase + // declarations BEFORE enrollment_complete so the Web timeline opens both bars + // for RJ-deferred-completion just like the direct SelfDeploying-terminal path. + var effects = new List(capacity: 4); + if (leadingEffect != null) effects.Add(leadingEffect); + effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.FinalizingSetup)); + effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.Complete)); + effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred")); + + return new DecisionStep(completedState, completedTransition, effects.ToArray()); + } } if (classicReady) diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs index b590bbfa..eb24df4e 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -9,7 +9,14 @@ namespace AutopilotMonitor.DecisionCore.Engine // SelfDeploying-v1 + Device-Only handlers. Plan §2.5 partial-class layout. public sealed partial class DecisionEngine { - // Plan §2.7: "5min timer after DeviceSetup; if no AccountSetup -> classified as device-only". + // 5-min wait between "DeviceSetup resolved" (DeviceSetupProvisioningComplete signal) and + // the SelfDeploying-terminal decision. The signal alone is NO LONGER terminal (88a53223 + // defang) — it just sets the DeviceSetupResolvedUtc anchor + arms this deadline. The + // deadline-fired handler is now the sole SelfDeploying-terminal entry point and re-checks + // all guards (Stage.IsTerminal, AccountSetupEntered, monotonic mode conflict) before + // committing. This trades a 5-min completion delay on real SelfDeploying devices for + // robust protection against false-positive SelfDeploying terminations on Classic flows + // where Windows transitioned slowly DeviceSetup→AccountSetup (the original 88a53223 bug). internal static readonly TimeSpan s_deviceOnlyEspDetectionWindow = TimeSpan.FromMinutes(5); /// @@ -27,75 +34,270 @@ namespace AutopilotMonitor.DecisionCore.Engine /// /// Handle . /// - /// Terminal only on the SelfDeploying / Device-Only path (no AccountSetup phase ever - /// entered). On the Classic UserDriven-v1 path — where - /// is already set — this is just the ProvisioningStatusTracker marking DeviceSetupCategory - /// resolved; completion on that path requires - /// + (handled by Classic.cs). + /// 88a53223 defang (Plan v9): this handler NO LONGER terminates. It only records + /// the anchor and (cancel-then-)arms + /// the deadline at + /// signal.OccurredAtUtc + 5min. The deadline-fired handler is the sole terminal + /// entry point and re-checks all guards. /// /// - /// Guarding on prevents the regression - /// observed in session e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22), where a late - /// DeviceSetupProvisioningComplete (payload deviceSetupResolved=unknown) fired while - /// Stage was and - /// was still null — terminating the session before Hello even ran. + /// Why: the previous "terminate immediately when AccountSetupEnteredUtc == null" logic + /// mis-classified Classic UserDriven flows as SelfDeploying when the ProvisioningStatusTracker + /// 30-s fallback fired DeviceSetupProvisioningComplete before Windows transitioned + /// DeviceSetup→AccountSetup (session 88a53223-9795-4188-8352-7df9f0af9bb7). Defanging + /// to deadline-only terminal classification protects against this — real SelfDeploying + /// devices still complete (5 min later), real Classic flows continue normally and the + /// AccountSetup signal cancels the deadline before it fires. /// /// private DecisionStep HandleDeviceSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal) { var nextStep = state.StepIndex + 1; - // Classic-path guard: AccountSetup already started → this signal is informational only. - if (state.AccountSetupEnteredUtc != null) + // Step 1 — Idempotency: anchor already set means we've already processed a previous + // DeviceSetupProvisioningComplete signal in this session. Replay-safe; the deadline + // was already armed (or already cancelled by AccountSetup). Pass-through. + if (state.DeviceSetupResolvedUtc != null) { var passthroughState = state.ToBuilder() .WithStepIndex(nextStep) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .Build(); - var passthroughTransition = BuildTakenTransition( before: state, signal: signal, toStage: state.Stage, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)); - + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete) + ":AnchorAlreadySet"); return new DecisionStep(passthroughState, passthroughTransition, Array.Empty()); } - // SelfDeploying / Device-Only terminal path. - // Codex follow-up #5: user-presence is now an observation-level check (late-AADJ - // user flag + signal-level Hello/Desktop facts) — NOT the legacy AadJoinedWithUser - // state field. Observations replace the legacy per-fact nullable bool. + // Step 2 — Set the DeviceSetupResolvedUtc anchor UNCONDITIONALLY. Even when Classic + // already entered AccountSetup (step 3 below), we record the anchor for observability + // (DecisionStateSignalCensus surfaces it in enrollment_complete audit). + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.DeviceSetupResolvedUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + + // Step 3 — Classic-path short-circuit: AccountSetup already started. The DeviceOnly + // deadline is moot (already cancelled by HandleEspPhaseChangedV1's AccountSetup branch, + // or never armed under the new code). No deadline arm here. + if (state.AccountSetupEnteredUtc != null) + { + var classicState = builder.Build(); + var classicTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete) + ":AccountSetupAlreadyEntered"); + return new DecisionStep(classicState, classicTransition, Array.Empty()); + } + + // Step 4 — Arm the deadline. Cancel any existing DeviceOnlyEspDetection first + // (rollout-safety: handles snapshots loaded from old-code sessions where the deadline + // was armed at DeviceSetup-START rather than -END). EffectiveDeadlineBase floors the + // 5-min window at AgentBootUtc so a replayed signal from an older CMTrace log doesn't + // collapse the deadline to immediate-fire at boot. + builder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + var deadline = BuildDeviceOnlyEspDetectionDeadline(EffectiveDeadlineBase(state, signal)); + builder.AddDeadline(deadline); + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete) + ":DeadlineArmed"); + + var effects = new[] + { + new DecisionEffect(DecisionEffectKind.CancelDeadline, cancelDeadlineName: DeadlineNames.DeviceOnlyEspDetection), + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: deadline), + }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Sole SelfDeploying-terminal entry point. Plan v9. + /// + /// Fires 5 min after + /// armed the deadline. Three stale-fire + /// guards (a/b/c) + three race guards precede the terminal branch — when any trips, the + /// handler dead-ends (no Stage change, no effects). Only when all guards pass does the + /// terminal SelfDeploying classification commit + emit phase declarations + + /// enrollment_complete. + /// + /// + private DecisionStep HandleDeviceOnlyEspDetectionDeadlineFired(DecisionState state, DecisionSignal signal) + { + // Look up the active deadline once — used by guards b/c and by the cancel-on-terminal + // builder mutation below. + ActiveDeadline? activeDeadline = null; + foreach (var d in state.Deadlines) + { + if (string.Equals(d.Name, DeadlineNames.DeviceOnlyEspDetection, StringComparison.Ordinal)) + { + activeDeadline = d; + break; + } + } + + // --- Stale-fire guard A: no anchor --- + // The signal that should have armed our new-style deadline never arrived. This is the + // rollout race: a session whose deadline was armed by old code (at DeviceSetup-START) + // fires under new code before DeviceSetupProvisioningComplete has set the anchor. + // Dead-end + state-side cancel the deadline (BumpStepBookkeeping alone wouldn't — and + // a persisted snapshot with the stale deadline would re-arm via the scheduler). + if (state.DeviceSetupResolvedUtc == null) + { + var staleNoAnchorState = state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection) + .Build(); + var staleNoAnchorTransition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: staleNoAnchorState.StepIndex, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}", + deadEndReason: "device_only_esp_detection_stale_no_anchor"); + return new DecisionStep(staleNoAnchorState, staleNoAnchorTransition, Array.Empty()); + } + + // --- Stale-fire guard B: deadline not armed --- + // Explicit cancel already happened (AccountSetup cancelled it, idempotent + // HandleDeviceSetupProvisioningCompleteV1 re-arm cancelled the previous one, …) but a + // stale DeadlineFired raced through. No state cleanup needed (nothing to cancel). + if (activeDeadline == null) + { + var bookkept = BumpStepBookkeeping(state, signal); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkept.StepIndex, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}", + deadEndReason: "device_only_esp_detection_stale_deadline_not_armed"); + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + // --- Stale-fire guard C: DueAtUtc mismatch --- + // Cancel-then-rearm race: an old DeadlineFired (from a deadline incarnation that was + // replaced by a later HandleDeviceSetupProvisioningCompleteV1 arming) is processed + // after the rearm. DeadlineScheduler posts OccurredAtUtc == deadline.DueAtUtc, so the + // OLD fire carries the OLD DueAtUtc. Dead-end WITHOUT cancelling the active deadline + // (the whole point is to keep the new one for its real fire). + if (activeDeadline.DueAtUtc != signal.OccurredAtUtc) + { + var bookkept = BumpStepBookkeeping(state, signal); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkept.StepIndex, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}", + deadEndReason: "device_only_esp_detection_stale_due_at_mismatch"); + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + // From here on the active deadline matched (guard C passed) and just fired. ANY + // dead-end below MUST state-side CancelDeadline(DeviceOnlyEspDetection) — otherwise + // BumpStepBookkeeping (which only advances Step/Ordinal) leaves the past-due deadline + // in state.Deadlines, the snapshot persists it, and on reload the scheduler re-arms + // it → repeat dead-end loop. This is distinct from the stale-fire DueAtUtc-mismatch + // path above, which intentionally KEEPS the newer active deadline. + + // --- Race guard A: Stage already terminal --- + // Hello+Desktop Classic completion, AdminPreemption, or ESP failure won the race + // between deadline-arm and deadline-fire. Don't double-terminate. + if (state.Stage.IsTerminal()) + { + var raceAState = state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection) + .Build(); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: raceAState.StepIndex, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}", + deadEndReason: "device_only_esp_detection_stage_already_terminal"); + return new DecisionStep(raceAState, transition, Array.Empty()); + } + + // --- Race guard B: AccountSetup entered between deadline-arm and -fire --- + // Defensive: the AccountSetup-cancel-deadline path normally prevents this; if it + // didn't, we still must not classify as SelfDeploying. + if (state.AccountSetupEnteredUtc != null) + { + var raceBState = state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection) + .Build(); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: raceBState.StepIndex, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}", + deadEndReason: "device_only_esp_detection_account_setup_entered"); + return new DecisionStep(raceBState, transition, Array.Empty()); + } + + // --- Race guard C: monotonic mode conflict --- + // Prior signal already classified the session more strongly on a different Mode + // (e.g. ImeUserSessionCompleted → Classic/High, or WhiteGloveSealingConfirmed → + // WhiteGlove/High). The session is genuinely not SelfDeploying. Don't relabel. + // Mirrors the precedent in ApplyImeUserSessionCompleted's monotonic guard. + if (state.ScenarioProfile.Confidence == ProfileConfidence.High + && state.ScenarioProfile.Mode != EnrollmentMode.Unknown + && state.ScenarioProfile.Mode != EnrollmentMode.SelfDeploying) + { + var raceCState = state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection) + .Build(); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: raceCState.StepIndex, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}", + deadEndReason: "device_only_esp_detection_monotonic_mode_conflict"); + return new DecisionStep(raceCState, transition, Array.Empty()); + } + + // All guards passed → this is a real SelfDeploying device. + + var nextStep = state.StepIndex + 1; var hasUserPresence = (state.ScenarioObservations.AadUserJoinWithUserObserved != null && state.ScenarioObservations.AadUserJoinWithUserObserved.Value) || state.HelloResolvedUtc != null || state.DesktopArrivedUtc != null; - var deviceOnlyReason = hasUserPresence ? DeviceOnlyReasons.UserPresent : DeviceOnlyReasons.DeviceOnly; - - var updatedDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With( + var confirmedDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With( level: HypothesisLevel.Confirmed, reason: deviceOnlyReason, score: 100, lastUpdatedUtc: signal.OccurredAtUtc); - // RealmJoin gate: when RJ is detected and unresolved, defer the SelfDeploying - // terminal transition. Record the classifier verdict + the deferred-completion - // marker on RealmJoinFacts so HandleRealmJoinResolvedV1 / HandleRealmJoinTimeoutDeadlineFired - // can short-circuit straight to Completed without re-routing through Classic - // Finalizing. + // RealmJoin gate: when RJ is detected and unresolved, defer the terminal transition. + // The deferred flag drives CompleteIfDeferredOrBookkeep when RJ resolves/timeouts. + // Plan v9: this is the V9 location for RJ-deferral (was at signal-time previously; + // the move avoids a premature RJ-resolve-before-5min completing the session). if (!RealmJoinGateOpen(state)) { var deferredBuilder = state.ToBuilder() .WithStepIndex(nextStep) - .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); - deferredBuilder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(updatedDeviceOnly); - deferredBuilder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete( - deferredBuilder.ScenarioProfile, signal); + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + deferredBuilder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(confirmedDeviceOnly); deferredBuilder.RealmJoinFacts = state.RealmJoinFacts.WithSelfDeployingDeferred(signal.SessionSignalOrdinal); var deferredState = deferredBuilder.Build(); @@ -104,78 +306,48 @@ namespace AutopilotMonitor.DecisionCore.Engine signal: signal, toStage: state.Stage, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete) + ":RealmJoinGateClosed"); + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}:RealmJoinGateClosed"); return new DecisionStep(deferredState, deferredTransition, Array.Empty()); } - var builder = state.ToBuilder() + // Terminal SelfDeploying branch. + var terminalBuilder = state.ToBuilder() .WithStage(SessionStage.Completed) .WithOutcome(SessionOutcome.EnrollmentComplete) .WithStepIndex(nextStep) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .ClearDeadlines(); - builder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(updatedDeviceOnly); + terminalBuilder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(confirmedDeviceOnly); + terminalBuilder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed( + state.ScenarioProfile, signal); - // Strengthen Mode: observing DeviceSetupProvisioningComplete without preceding - // AccountSetup strongly implies SelfDeploying-v1. Delegated to the updater. - builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete( - builder.ScenarioProfile, signal); - - var newState = builder.Build(); - - var transition = BuildTakenTransition( + var terminalState = terminalBuilder.Build(); + var terminalTransition = BuildTakenTransition( before: state, signal: signal, toStage: SessionStage.Completed, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)); - - var effects = new[] { BuildEnrollmentCompleteEffect(newState, nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)) }; - - return new DecisionStep(newState, transition, effects); - } - - /// - /// Called by the shared DeadlineFired router when - /// elapses without an AccountSetup - /// phase having arrived. Confirms the - /// hypothesis as DeviceOnly but does NOT complete the session — the terminal still - /// needs an explicit . - /// - private DecisionStep HandleDeviceOnlyEspDetectionDeadlineFired(DecisionState state, DecisionSignal signal) - { - var nextStep = state.StepIndex + 1; - var builder = state.ToBuilder() - .WithStepIndex(nextStep) - .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) - .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); - - var strongDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With( - level: HypothesisLevel.Strong, - reason: DeviceOnlyReasons.DeviceOnly, - score: 70, - lastUpdatedUtc: signal.OccurredAtUtc); - builder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(strongDeviceOnly); - - var newState = builder.Build(); - - var transition = BuildTakenTransition( - before: state, - signal: signal, - toStage: state.Stage, - nextStepIndex: nextStep, trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); - return new DecisionStep(newState, transition, Array.Empty()); + // Effects sequence — UI phase coverage (Plan v9 Phase 4): emit FinalizingSetup + + // Complete phase declarations BEFORE enrollment_complete so the Web timeline opens + // both phase bars for SelfDeploying terminal sessions (was missing entirely before). + var effects = new DecisionEffect[] + { + BuildPhaseTransitionEffect(EnrollmentPhase.FinalizingSetup), + BuildPhaseTransitionEffect(EnrollmentPhase.Complete), + BuildEnrollmentCompleteEffect(terminalState, $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"), + }; + + return new DecisionStep(terminalState, terminalTransition, effects); } // ============================================================== internal helpers /// - /// Called from the Classic handler when - /// DeviceSetup is observed for the first time — arms the - /// deadline. The Classic handler - /// adds the deadline effect to its own effect list. + /// Build the active deadline. Plan v9: + /// armed by at DeviceSetup-END (not + /// at DeviceSetup-START like the legacy v1 code). /// internal ActiveDeadline BuildDeviceOnlyEspDetectionDeadline(DateTime fromUtc) => new ActiveDeadline( diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 7a29040f..793d551d 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -282,20 +282,28 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: $"AdminPreemption:{adminOutcome}"); - var effects = new[] + // Plan v9 Phase 4 — UI phase coverage: for AdminPreemption-Succeeded, prepend + // FinalizingSetup + Complete phase declarations so the Web timeline opens both bars + // (parity with Classic FinalizingGrace + SelfDeploying-deadline terminal paths). + // For AdminPreemption-Failed, enrollment_failed already opens the Failed bar via + // the existing UI logic — no phase_transitions needed. + var effects = new List(capacity: 3); + if (succeeded) { - new DecisionEffect( - DecisionEffectKind.EmitEventTimelineEntry, - parameters: new Dictionary - { - ["eventType"] = eventType, - ["adminAction"] = adminOutcome, - ["source"] = signal.SourceOrigin ?? "register_session_response", - ["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).", - }), - }; + effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.FinalizingSetup)); + effects.Add(BuildPhaseTransitionEffect(EnrollmentPhase.Complete)); + } + effects.Add(new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = eventType, + ["adminAction"] = adminOutcome, + ["source"] = signal.SourceOrigin ?? "register_session_response", + ["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).", + })); - return new DecisionStep(newState, transition, effects); + return new DecisionStep(newState, transition, effects.ToArray()); } // ============================================================== shared helpers diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs index f2510c55..91d13fff 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs @@ -90,6 +90,16 @@ namespace AutopilotMonitor.DecisionCore.Engine timestamps["accountSetupProvisioningComplete"] = FormatUtc(state.AccountSetupProvisioningSucceededUtc.Value); evidence["accountSetupProvisioningComplete"] = TimestampedEvidence(state.AccountSetupProvisioningSucceededUtc); } + // SelfDeploying-defang anchor — set when DeviceSetupProvisioningComplete signal + // arrives. Surfaces in enrollment_complete audit so consumers can see whether the + // DeviceSetup-done observation drove the SelfDeploying classification (via the new + // 5-min DeviceOnlyEspDetection deadline path). + if (state.DeviceSetupResolvedUtc != null) + { + seen.Add("device_setup_provisioning_complete"); + timestamps["deviceSetupResolved"] = FormatUtc(state.DeviceSetupResolvedUtc.Value); + evidence["deviceSetupResolved"] = TimestampedEvidence(state.DeviceSetupResolvedUtc); + } // WhiteGlove Part-1 sealing observations — engine-internal, no UTC fact. var obs = state.ScenarioObservations; diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs index 3d3082b5..1fb1b23f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs @@ -50,6 +50,7 @@ namespace AutopilotMonitor.DecisionCore.Engine { ["currentEnrollmentPhase"] = SerializeFact(state.CurrentEnrollmentPhase, v => v.ToString()), ["deviceSetupEnteredUtc"] = SerializeFact(state.DeviceSetupEnteredUtc, FormatUtc), + ["deviceSetupResolvedUtc"] = SerializeFact(state.DeviceSetupResolvedUtc, FormatUtc), ["accountSetupEnteredUtc"] = SerializeFact(state.AccountSetupEnteredUtc, FormatUtc), ["accountSetupProvisioningSucceededUtc"] = SerializeFact(state.AccountSetupProvisioningSucceededUtc, FormatUtc), ["finalizingEnteredUtc"] = SerializeFact(state.FinalizingEnteredUtc, FormatUtc), diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs index 7a74507f..c041e2a6 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -30,7 +30,7 @@ namespace AutopilotMonitor.DecisionCore.State /// public sealed class DecisionState { - public const string CurrentSchemaVersion = "v3"; + public const string CurrentSchemaVersion = "v4"; public DecisionState( string sessionId, @@ -59,6 +59,7 @@ namespace AutopilotMonitor.DecisionCore.State DateTime? agentBootUtc = null, SignalFact? lastFailureTrigger = null, RealmJoinFacts? realmJoinFacts = null, + SignalFact? deviceSetupResolvedUtc = null, string? schemaVersion = null) { if (string.IsNullOrEmpty(sessionId)) @@ -97,6 +98,7 @@ namespace AutopilotMonitor.DecisionCore.State AgentBootUtc = agentBootUtc; LastFailureTrigger = lastFailureTrigger; RealmJoinFacts = realmJoinFacts ?? RealmJoinFacts.Empty; + DeviceSetupResolvedUtc = deviceSetupResolvedUtc; SchemaVersion = schemaVersion ?? CurrentSchemaVersion; } @@ -231,6 +233,22 @@ namespace AutopilotMonitor.DecisionCore.State /// public RealmJoinFacts RealmJoinFacts { get; } + /// + /// Set when is + /// observed — the "DeviceSetup phase is done" anchor. Drives the new SelfDeploying + /// classification path: arming the + /// deadline from this anchor (5-min wait) replaces the v1 "terminate-on-signal" behavior + /// that mis-classified Classic UserDriven enrollments as SelfDeploying when Windows + /// transitioned slowly DeviceSetup→AccountSetup. + /// + /// Null until DeviceSetupProvisioningComplete arrives; the signal handler sets + /// this fire-once. The new HandleDeviceOnlyEspDetectionDeadlineFired stale-fire + /// guard treats null here as "deadline armed by old code path before this anchor + /// existed" and dead-ends, ensuring rollout safety. + /// + /// + public SignalFact? DeviceSetupResolvedUtc { get; } + public string SchemaVersion { get; } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs index eb089f6a..14996e31 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -45,6 +45,7 @@ namespace AutopilotMonitor.DecisionCore.State AgentBootUtc = source.AgentBootUtc; LastFailureTrigger = source.LastFailureTrigger; RealmJoinFacts = source.RealmJoinFacts; + DeviceSetupResolvedUtc = source.DeviceSetupResolvedUtc; SchemaVersion = source.SchemaVersion; } @@ -74,6 +75,7 @@ namespace AutopilotMonitor.DecisionCore.State public DateTime? AgentBootUtc { get; set; } public SignalFact? LastFailureTrigger { get; set; } public RealmJoinFacts RealmJoinFacts { get; set; } = RealmJoinFacts.Empty; + public SignalFact? DeviceSetupResolvedUtc { get; set; } public string SchemaVersion { get; set; } // ---------- fluent helpers for the most common reducer operations ---------- @@ -209,6 +211,7 @@ namespace AutopilotMonitor.DecisionCore.State agentBootUtc: AgentBootUtc, lastFailureTrigger: LastFailureTrigger, realmJoinFacts: RealmJoinFacts, + deviceSetupResolvedUtc: DeviceSetupResolvedUtc, schemaVersion: SchemaVersion); } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs index 82f31ade..1bd59a06 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs @@ -243,15 +243,25 @@ namespace AutopilotMonitor.DecisionCore.State evidenceOrdinal: signal.SessionSignalOrdinal); } - // ================================================================== DeviceSetupProvisioningComplete + // ================================================================== SelfDeployingDeadlineConfirmed /// - /// without a preceding - /// AccountSetup is a strong SelfDeploying-v1 indicator (caller must guard that invariant). + /// SelfDeploying classification is now driven exclusively by the 5-min + /// deadline firing without + /// AccountSetup having entered (NOT by the raw + /// signal — which + /// previously caused false-positive SelfDeploying terminations on Classic UserDriven + /// flows where Windows transitioned slowly DeviceSetup→AccountSetup, e.g. session + /// 88a53223-9795-4188-8352-7df9f0af9bb7). + /// /// Upgrades Mode to at - /// . + /// with the same monotonic guard as + /// : a prior High-confidence classification on + /// a different Mode wins. Belt-and-suspenders alongside the deadline-handler's Race + /// guard C ("monotonic mode conflict"). + /// /// - public static EnrollmentScenarioProfile ApplyDeviceSetupProvisioningComplete( + public static EnrollmentScenarioProfile ApplySelfDeployingDeadlineConfirmed( EnrollmentScenarioProfile current, DecisionSignal signal) { @@ -267,7 +277,7 @@ namespace AutopilotMonitor.DecisionCore.State return current.With( mode: EnrollmentMode.SelfDeploying, confidence: ProfileConfidence.High, - reason: "selfdeploying_provisioning_complete", + reason: "selfdeploying_deadline_confirmed", evidenceOrdinal: signal.SessionSignalOrdinal); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs index 1d9cc768..83e38fd2 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs @@ -133,6 +133,24 @@ namespace AutopilotMonitor.DecisionCore.State packages: Packages); } + /// + /// Clear back to null. Called by the RJ-deferred + /// release re-check branch in CompleteIfDeferredOrBookkeep when post-deadline + /// signals (AccountSetup entry, monotonic Mode conflict) demote the deferred SelfDeploying + /// path back to Classic flow. Idempotent — returns this when already null. + /// + public RealmJoinFacts ClearSelfDeployingDeferred() + { + if (SelfDeployingDeferredCompletion == null) return this; + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: null, + packages: Packages); + } + /// /// Append a new package row when the package is first observed (DisplayName seen, /// no Success/LastExitCode yet). Idempotent on (, diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl index b76a531f..b51be5d2 100644 --- a/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl +++ b/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl @@ -1,11 +1,13 @@ # Scenario: SelfDeploying Device-Only with ESP-Exit-Unknown -# DeviceSetup phase arrives, but no AccountSetup follows within the 5-minute -# DeviceOnlyEspDetection window — the deadline fires and classifies the session -# as DeviceOnly. Terminal comes via DeviceSetupProvisioningComplete afterwards. +# Plan v9 (88a53223 defang) — semantics: DeviceSetupProvisioningComplete arms the +# DeviceOnlyEspDetection 5-min deadline at signal-time (not at DeviceSetup-START like the v1 +# code). When the deadline fires later AND no AccountSetup arrived in the interval, the handler +# terminates as SelfDeploying (after all guards pass). # Expected: Stage=Completed, Outcome=EnrollmentComplete, -# DeviceOnlyDeployment=Confirmed/Strong progression, no user-presence facts. +# DeviceOnlyDeployment=Confirmed (reason=device_only), no user-presence facts, +# ScenarioProfile.Mode=SelfDeploying@High. # Plan: §4 M3.2 — "SelfDeploying-v1 mit ESP-Exit-Unknown -> Device-Only-ESP-Timer -> Complete". {"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:device-only:started","Summary":"Session start anchor"}} {"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} -{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:06:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"Device-only ESP detection window elapsed without AccountSetup"},"Payload":{"deadline":"device_only_esp_detection"}} -{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:08:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:08:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:13:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"DeviceOnlyEspDetection 5-min window elapsed without AccountSetup."},"Payload":{"deadline":"device_only_esp_detection"}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl index ae0dbcc8..0bf1eced 100644 --- a/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl +++ b/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl @@ -1,10 +1,13 @@ # Scenario: SelfDeploying-v1 Happy (SkipUserStatusPage) # Device-level ESP only, no AccountSetup phase, no Hello, no Desktop. -# Terminal signal is DeviceSetupProvisioningComplete. +# Plan v9 (88a53223 defang): DeviceSetupProvisioningComplete is NO LONGER terminal — it just +# arms the DeviceOnlyEspDetection deadline (5 min). The deadline-fired handler is the sole +# terminal entry point and re-checks all guards before classifying as SelfDeploying. # Expected: Stage=Completed, Outcome=EnrollmentComplete, # DeviceOnlyDeployment=Confirmed(reason=device_only) (no user-presence facts), -# EnrollmentType=Strong(selfdeploying_provisioning_complete). -# Plan: §4 M3.2 canonical SelfDeploying scenario. +# ScenarioProfile.Mode=SelfDeploying@High (reason=selfdeploying_deadline_confirmed). +# Plan: §4 M3.2 canonical SelfDeploying scenario, post-defang. {"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:selfdeploying-happy:started","Summary":"Session start anchor"}} {"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup (SkipUserStatusPage)","DerivationInputs":{"rawValue":"1","skipUserStatusPage":"true"}},"Payload":{"phase":"DeviceSetup"}} {"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:08:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"DeviceOnlyEspDetection 5-min window elapsed — no AccountSetup observed, all guards pass → terminal SelfDeploying."},"Payload":{"deadline":"device_only_esp_detection"}}