diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs index 42a4a659..1cc2e56f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs @@ -56,11 +56,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program // The contract under test is the payload SHAPE, not the detected values. Assert.Contains("enrollmentType", (IDictionary)payload!); Assert.Contains("isHybridJoin", (IDictionary)payload); + Assert.Contains("isSelfDeployingProfile", (IDictionary)payload); // Value sanity — defaults must be parseable booleans / known type literals. var hybrid = payload["isHybridJoin"]; Assert.True(hybrid == "true" || hybrid == "false"); var type = payload["enrollmentType"]; Assert.True(type == "v1" || type == "v2"); + // Session 320b3bf7 kiosk fix — the OobeConfig self-deploying marker rides the + // same facts signal (safe default "false" off-device). + var selfDeploying = payload["isSelfDeployingProfile"]; + Assert.True(selfDeploying == "true" || selfDeploying == "false"); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EnrollmentRegistryDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EnrollmentRegistryDetectorTests.cs index c78a6aae..441c9b3a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EnrollmentRegistryDetectorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EnrollmentRegistryDetectorTests.cs @@ -127,5 +127,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security var actual = EnrollmentRegistryDetector.DetectHybridJoin(); Assert.IsType(actual); } + + // ============================================== self-deploying profile (OobeConfig) + // Session 320b3bf7 kiosk fix: CloudAssignedOobeConfig bits 0x20|0x40 mark a + // self-deploying profile (platform sweep 2026-07-02: exclusive to SD/kiosk/shared + // profiles, both bits always co-occur, zero user-driven false positives). + + [Theory] + [InlineData(0x60, true)] // exactly the mask + [InlineData(1534, true)] // 0x5FE — Opsys Kiosk (session 320b3bf7) + [InlineData(510, true)] // 0x1FE — second observed SD value + [InlineData(0x20, false)] // TPM-attestation bit alone + [InlineData(0x40, false)] // device-auth bit alone + [InlineData(1310, false)] // 0x51E — dominant user-driven value + [InlineData(286, false)] // 0x11E — user-driven + [InlineData(0, false)] + public void TopLevel_OobeConfig_requires_both_selfdeploying_bits(int oobeConfig, bool expected) + { + var values = new Dictionary + { + ["CloudAssignedOobeConfig"] = oobeConfig, + }; + Assert.Equal(expected, EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values))); + } + + [Fact] + public void TopLevel_OobeConfig_nonnumeric_returns_false_without_fallback() + { + // Top-level value is authoritative when present — an unparseable top-level does + // NOT fall through to a contradicting JSON blob (mirrors the hybrid-join contract). + var values = new Dictionary + { + ["CloudAssignedOobeConfig"] = "not-a-number", + ["PolicyJsonCache"] = "{\"CloudAssignedOobeConfig\":1534}", + }; + Assert.False(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values))); + } + + [Fact] + public void Missing_TopLevel_OobeConfig_falls_back_to_PolicyJsonCache() + { + // Real-world shape from session 320b3bf7 (Opsys Kiosk): OobeConfig only inside + // the embedded JSON blob, DeploymentProfileName "Opsys Kiosk". + const string policyJson = + "{\r\n \"CloudAssignedTenantDomain\": \"contoso.onmicrosoft.com\",\r\n" + + " \"CloudAssignedOobeConfig\": 1534,\r\n" + + " \"DeploymentProfileName\": \"Contoso Kiosk\"\r\n}"; + var values = new Dictionary + { + ["PolicyJsonCache"] = policyJson, + }; + Assert.True(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values))); + } + + [Fact] + public void Missing_TopLevel_OobeConfig_PolicyJsonCache_userdriven_returns_false() + { + var values = new Dictionary + { + ["PolicyJsonCache"] = "{\"CloudAssignedOobeConfig\":1310}", + }; + Assert.False(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values))); + } + + [Fact] + public void OobeConfig_absent_everywhere_returns_false() + { + var values = new Dictionary + { + ["PolicyJsonCache"] = "{\"CloudAssignedTenantDomain\":\"contoso.onmicrosoft.com\"}", + }; + Assert.False(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values))); + } + + [Fact] + public void OobeConfig_malformed_PolicyJsonCache_returns_false_without_throw() + { + var values = new Dictionary + { + ["PolicyJsonCache"] = "{not valid json", + }; + Assert.False(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values))); + } + + [Fact] + public void OobeConfig_null_lookup_returns_false() + { + Assert.False(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(null)); + } + + [Fact] + public void DetectSelfDeployingProfile_returns_false_when_registry_key_is_unreadable() + { + var actual = EnrollmentRegistryDetector.DetectSelfDeployingProfile(); + Assert.IsType(actual); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs index 804e33cf..7b86e8d7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs @@ -18,6 +18,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security private const string AutopilotPolicyCacheKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"; private const string AutopilotSettingsKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"; + /// + /// CloudAssignedOobeConfig bits 0x20 (TPM attestation) + 0x40 (AAD device-ticket + /// auth) — set together exclusively on self-deploying Autopilot profiles. Validated + /// across the full platform (2026-07-02 sweep, 8983 sessions / 63 tenants): all 1197 + /// sessions carrying both bits belong to self-deploying/kiosk/shared-device profiles, + /// zero of 6785 user-driven sessions carry them, and the two bits never occur + /// individually. Session 320b3bf7 is the reference kiosk case this detection unblocks. + /// + internal const int SelfDeployingOobeConfigMask = 0x60; + /// /// Reads CloudAssignedDomainJoinMethod from the Autopilot policy cache. Returns /// true when the profile was deployed with Hybrid Azure AD Join @@ -88,6 +98,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Security return false; } + /// + /// Reads CloudAssignedOobeConfig from the Autopilot policy cache and returns + /// true when both self-deploying marker bits () + /// are set. Mirrors the top-level-value-then- + /// PolicyJsonCache-JSON fallback, because the same Windows builds that omit the + /// flat CloudAssignedDomainJoinMethod value also omit CloudAssignedOobeConfig. + /// Returns false on any error — a missing/unreadable profile must degrade to + /// today's classic behaviour, never to a false self-deploying classification. + /// + public static bool DetectSelfDeployingProfile() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(AutopilotPolicyCacheKey)) + { + if (key == null) return false; + return ResolveSelfDeployingFromValues(key.GetValue); + } + } + catch + { + return false; + } + } + + /// + /// Pure decision logic for . Exposed internally + /// so the fallback can be exercised without a real registry (same seam contract as + /// ). + /// + internal static bool ResolveSelfDeployingFromValues(Func getValue) + { + if (getValue == null) return false; + + // Top-level value is authoritative when present. + var topLevel = getValue("CloudAssignedOobeConfig")?.ToString(); + if (topLevel != null) + { + return TryParseOobeConfig(topLevel, out var cfg) && HasSelfDeployingBits(cfg); + } + + // Fallback: embedded JSON blob (mirrors the hybrid-join fallback). + var policyJson = getValue("PolicyJsonCache")?.ToString(); + if (string.IsNullOrWhiteSpace(policyJson)) return false; + + try + { + using (var doc = System.Text.Json.JsonDocument.Parse(policyJson)) + { + if (doc.RootElement.TryGetProperty("CloudAssignedOobeConfig", out var prop)) + { + return TryParseOobeConfig(prop.ToString(), out var cfg) && HasSelfDeployingBits(cfg); + } + } + } + catch + { + // Malformed PolicyJsonCache → conservative non-self-deploying default. + } + return false; + } + + private static bool TryParseOobeConfig(string raw, out int cfg) => + int.TryParse(raw, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out cfg); + + private static bool HasSelfDeployingBits(int cfg) => + (cfg & SelfDeployingOobeConfigMask) == SelfDeployingOobeConfigMask; + /// /// Classifies the Autopilot flow based on the AutopilotSettings registry: /// diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs index 755592e6..cc4f03c2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs @@ -161,6 +161,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security AgentVersion = agentVersion ?? string.Empty, EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(), IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(), + IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(), + // Deliberately stays true even for self-deploying profiles: the WhiteGlove + // Part1/Part2 merge paths and existing dashboards key on IsUserDriven; + // IsSelfDeployingProfile is the additive classification signal. IsUserDriven = true, }; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index 65581195..2b129393 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -303,11 +303,13 @@ namespace AutopilotMonitor.Agent.V2.Runtime { var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(); var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(); + var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile(); var payload = new Dictionary(StringComparer.Ordinal) { ["enrollmentType"] = enrollmentType, ["isHybridJoin"] = isHybridJoin ? "true" : "false", + ["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false", }; var evidence = new Evidence( @@ -322,7 +324,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime evidence: evidence, payload: payload); - logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin})."); + logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying})."); } catch (Exception ex) { diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs index a9b1a465..51cd668d 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs @@ -115,5 +115,164 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios d => d.Name == DeadlineNames.DeviceOnlyEspDetection); Assert.Equal(HypothesisLevel.Unknown, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level); } + + // ============================================================ kiosk waiver (320b3bf7) + // Session 320b3bf7: self-deploying kiosk devices never completed because the IME logs a + // false-positive AccountSetup phase line for the kioskUser0 autologon session. The + // OobeConfig 0x20|0x40 seed (EnrollmentFactsObserved isSelfDeployingProfile) plus the + // kiosk waiver keep the DeviceOnlyEspDetection terminal path alive. + + [Fact] + public void KioskImeAccountSetup_falsePositiveBeforeArm_completesViaDeadline() + { + // 320b3bf7 replay shape: AccountSetup false positive lands BEFORE + // DeviceSetupProvisioningComplete — pre-fix the arm was short-circuited + // (":AccountSetupAlreadyEntered") and the session idled to the 5h timeout. + var result = RunFixture( + fixtureFilename: "selfdeploying-kiosk-ime-accountsetup-v1.jsonl", + sessionId: "session-anon-kiosk-01", + tenantId: "tenant-anon-kiosk"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Empty(result.FinalState.Deadlines); + + Assert.Equal(EnrollmentMode.SelfDeploying, result.FinalState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence); + Assert.Equal("selfdeploying_deadline_confirmed", result.FinalState.ScenarioProfile.Reason); + + // The false-positive AccountSetup entry is retained as an audit fact — the census + // documents it in the enrollment_complete event rather than hiding it. + Assert.NotNull(result.FinalState.AccountSetupEnteredUtc); + Assert.Null(result.FinalState.AccountSetupProvisioningSucceededUtc); + + // Desktop (kioskUser0 autologon) was seen → user_present flavor of DeviceOnly. + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level); + Assert.Equal( + DecisionEngine.DeviceOnlyReasons.UserPresent, + result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason); + + var deadlineTransition = Assert.Single( + result.Transitions, + t => t.Trigger == $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); + Assert.True(deadlineTransition.Taken); + Assert.Equal(SessionStage.Completed, deadlineTransition.ToStage); + } + + [Fact] + public void KioskImeAccountSetup_falsePositiveInsideWindow_deadlineSurvivesAndCompletes() + { + // Reverse ordering: deadline armed first, false positive lands inside the 5-min + // window — pre-fix HandleEspPhaseChangedV1 cancelled the armed deadline here. + var result = RunFixture( + fixtureFilename: "selfdeploying-kiosk-accountsetup-in-window-v1.jsonl", + sessionId: "session-anon-kiosk-02", + tenantId: "tenant-anon-kiosk"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal(EnrollmentMode.SelfDeploying, result.FinalState.ScenarioProfile.Mode); + // No desktop/user facts in this variant → device_only flavor. + Assert.Equal( + DecisionEngine.DeviceOnlyReasons.DeviceOnly, + result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason); + + // The AccountSetup transition must NOT have produced a CancelDeadline for + // DeviceOnlyEspDetection — proven by the deadline actually firing terminal. + var deadlineTransition = Assert.Single( + result.Transitions, + t => t.Trigger == $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); + Assert.True(deadlineTransition.Taken); + } + + [Fact] + public void KioskRebootReplay_staleFireAndDuplicates_completeExactlyOnce() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-kiosk-reboot-replay-v1.jsonl", + sessionId: "session-anon-kiosk-03", + tenantId: "tenant-anon-kiosk"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + + // Exactly ONE taken terminal DeadlineFired transition; the stale fire (DueAtUtc + // mismatch) and the post-terminal duplicate are dead-ends. + var terminalFires = result.Transitions + .Where(t => t.Taken && t.ToStage == SessionStage.Completed) + .ToList(); + Assert.Single(terminalFires); + Assert.Contains(result.Transitions, t => + t.DeadEndReason == "device_only_esp_detection_stale_due_at_mismatch"); + Assert.Contains(result.Transitions, t => + t.DeadEndReason != null && t.DeadEndReason.StartsWith("signal_after_terminal")); + } + + [Fact] + public void KioskBitsWithWhiteGloveConfirm_whiteGloveWins_noSelfDeployingTerminal() + { + // WG sealing confirm terminates as WhiteGloveSealed and clears deadlines; the late + // DeviceOnly fire must dead-end. Pins the defensive order for the two anomalous + // preProv sessions the sweep found carrying the SD bits. + var result = RunFixture( + fixtureFilename: "selfdeploying-bits-whiteglove-reclassified-v1.jsonl", + sessionId: "session-anon-kiosk-04", + tenantId: "tenant-anon-kiosk"); + + Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome); + Assert.Equal(EnrollmentMode.WhiteGlove, result.FinalState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence); + // The post-seal DeadlineFired is a dead-end, never a SelfDeploying terminal: the + // WG-Confirmed verdict cleared all deadlines, so stale-fire guard B catches the + // late fire (the after-terminal dispatch only intercepts Completed/Failed — + // WhiteGloveSealed routes into the handler, whose guards must hold). + Assert.Contains(result.Transitions, t => + t.DeadEndReason == "device_only_esp_detection_stale_deadline_not_armed"); + } + + [Fact] + public void KioskBitsWithRealUserEspProgress_vetoReenabled_noDeadlineArmed() + { + // A genuine AccountSetupProvisioningComplete switches the kiosk waiver OFF: the + // DSPC handler takes the Classic short-circuit, no DeviceOnly deadline is armed, + // the session stays on the Classic completion path. + var result = RunFixture( + fixtureFilename: "selfdeploying-kiosk-user-esp-progress-v1.jsonl", + sessionId: "session-anon-kiosk-05", + tenantId: "tenant-anon-kiosk"); + + Assert.NotEqual(SessionStage.Completed, result.FinalState.Stage); + Assert.DoesNotContain( + result.FinalState.Deadlines, + d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.NotNull(result.FinalState.AccountSetupProvisioningSucceededUtc); + Assert.Contains(result.Transitions, t => + t.Trigger != null && t.Trigger.EndsWith(":AccountSetupAlreadyEntered")); + } + + [Fact] + public void KioskHybridRealmJoinDeferred_releaseHonoursWaiver_completes() + { + // Deferred-release path (CompleteIfDeferredOrBookkeep): pre-fix the AccountSetup + // false positive cleared the deferred flag on RJ-resolve and the session parked + // forever. The waiver keeps the deferred SelfDeploying terminal. + var result = RunFixture( + fixtureFilename: "selfdeploying-kiosk-hybrid-rj-deferred-v1.jsonl", + sessionId: "session-anon-kiosk-06", + tenantId: "tenant-anon-kiosk"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal(EnrollmentMode.SelfDeploying, result.FinalState.ScenarioProfile.Mode); + Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence); + Assert.NotNull(result.FinalState.RealmJoinFacts.ResolvedUtc); + + var releaseTransition = Assert.Single( + result.Transitions, + t => t.Trigger != null && t.Trigger.EndsWith(":SelfDeployingDeferred")); + Assert.True(releaseTransition.Taken); + Assert.Equal(SessionStage.Completed, releaseTransition.ToStage); + } } } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs index 507cd8cd..c64a4f59 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SelfDeployingDeadlineTerminalTests.cs @@ -154,6 +154,124 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); } + // ============================================================== #3b Kiosk waiver (session 320b3bf7) + + private static DecisionState SeedSelfDeployingProfile(DecisionEngine engine, DecisionState state, long ordinal) + => engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(1), + new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + [SignalPayloadKeys.IsSelfDeployingProfile] = "true", + })).NewState; + + [Fact] + public void KioskWaiver_dspcArmsDeadline_despiteAccountSetupEntered() + { + // Session 320b3bf7 ordering: the IME AccountSetup false positive lands BEFORE + // DeviceSetupProvisioningComplete. With the OobeConfig seed the step-3 + // short-circuit is waived and the deadline still arms. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-kiosk-1", "t", T0)); + state = SeedSelfDeployingProfile(engine, state, 2); + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + Assert.NotNull(state.AccountSetupEnteredUtc); + + var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))); + + Assert.EndsWith(":DeadlineArmed", step.Transition.Trigger); + var deadline = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(T0.AddMinutes(8), deadline.DueAtUtc); + + // And the fire completes terminal — race guard B is waived for the seeded profile. + var terminal = engine.Reduce(step.NewState, MakeSignal(5, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + Assert.Equal(SessionStage.Completed, terminal.NewState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, terminal.NewState.Outcome); + } + + [Fact] + public void KioskWaiver_accountSetupInsideWindow_doesNotCancelDeadline() + { + // Reverse ordering: deadline armed, false positive lands inside the 5-min window. + // HandleEspPhaseChangedV1 must NOT cancel the deadline for the seeded profile. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-kiosk-2", "t", T0)); + state = SeedSelfDeployingProfile(engine, state, 2); + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + Assert.Contains(state.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + + var accountSetupStep = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(4), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })); + + Assert.Contains(accountSetupStep.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.DoesNotContain(accountSetupStep.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline); + + var terminal = engine.Reduce(accountSetupStep.NewState, MakeSignal(5, DecisionSignalKind.DeadlineFired, T0.AddMinutes(8), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection })); + Assert.Equal(SessionStage.Completed, terminal.NewState.Stage); + } + + [Fact] + public void KioskWaiver_notAppliedWithoutSeed_hotpathUnchanged() + { + // Byte-identical hotpath proof at the unit level: WITHOUT the OobeConfig seed the + // AccountSetup entry cancels the deadline exactly as before (existing behaviour, + // duplicated here as an explicit invariant next to the waiver tests). + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-kiosk-3", "t", T0)); + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + + var accountSetupStep = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(4), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })); + + Assert.DoesNotContain(accountSetupStep.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Contains(accountSetupStep.Effects, e => + e.Kind == DecisionEffectKind.CancelDeadline + && e.CancelDeadlineName == DeadlineNames.DeviceOnlyEspDetection); + } + + [Fact] + public void KioskWaiver_userEspProgress_reenablesVeto_shortCircuitsArm() + { + // A genuine AccountSetupProvisioningComplete on a seeded profile switches the + // waiver OFF — DSPC takes the Classic short-circuit and never arms the deadline. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-kiosk-4", "t", T0)); + state = SeedSelfDeployingProfile(engine, state, 2); + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(4))); + + Assert.EndsWith(":AccountSetupAlreadyEntered", step.Transition.Trigger); + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + } + + [Fact] + public void KioskWaiver_guardB_notWaivedWhenUserEspProgressedAfterArm() + { + // Deadline armed first, then genuine user-ESP progress before the fire — guard B + // must veto the terminal (unwaived) and cancel the past-due deadline state-side. + var engine = new DecisionEngine(); + var state = PrimeDeviceSetup(engine, DecisionState.CreateInitial("sd-kiosk-5", "t", T0)); + state = SeedSelfDeployingProfile(engine, state, 2); + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(4), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(5))).NewState; + var deadline = state.Deadlines.Single(d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + + var step = engine.Reduce(state, MakeSignal(6, 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.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + } + // ============================================================== #5 RJ closed at signal, resolved before deadline [Fact] diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs index b4265d3b..ede25da0 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs @@ -111,6 +111,118 @@ namespace AutopilotMonitor.DecisionCore.Tests.State Assert.Equal(0, p2.EvidenceOrdinal); } + // ------------------------------------------------ isSelfDeployingProfile seed + // Session 320b3bf7 kiosk fix: CloudAssignedOobeConfig 0x20|0x40 marker seeds + // Mode=SelfDeploying at High confidence so the IME AccountSetup false positive + // can no longer flip the session to Classic and suppress the DeviceOnly path. + + [Fact] + public void EnrollmentFactsObserved_selfDeployingProfile_setsSelfDeploying_highConfidence() + { + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + [SignalPayloadKeys.IsSelfDeployingProfile] = "true", + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, signal); + + Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode); + Assert.Equal(ProfileConfidence.High, profile.Confidence); + Assert.Equal("oobe_config_self_deploying", profile.Reason); + Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode); + } + + [Theory] + [InlineData("false")] + [InlineData("not-a-bool")] + public void EnrollmentFactsObserved_selfDeployingProfile_falseOrUnparseable_leavesModeUnknown(string raw) + { + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsSelfDeployingProfile] = raw, + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, signal); + + Assert.Equal(EnrollmentMode.Unknown, profile.Mode); + Assert.Equal("enrollment_facts_observed:v1", profile.Reason); + } + + [Fact] + public void EnrollmentFactsObserved_selfDeployingProfile_repeatPost_isIdempotent() + { + Dictionary Payload() => new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + [SignalPayloadKeys.IsSelfDeployingProfile] = "true", + }; + + var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, + MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Payload())); + var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + p1, + MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Payload())); + + // Every dimension already set → set-once guards short-circuit, same instance back. + Assert.Same(p1, p2); + Assert.Equal(0, p2.EvidenceOrdinal); + } + + [Fact] + public void EnrollmentFactsObserved_v2_beatsSelfDeployingProfile_inSamePayload() + { + // WDP (Windows Device Preparation) has its own completion path — an enrollmentType + // "v2" in the same payload wins over the OobeConfig marker. + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v2", + [SignalPayloadKeys.IsSelfDeployingProfile] = "true", + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, signal); + + Assert.Equal(EnrollmentMode.DevicePreparation, profile.Mode); + } + + [Theory] + [InlineData(EnrollmentMode.Classic)] + [InlineData(EnrollmentMode.WhiteGlove)] + [InlineData(EnrollmentMode.DevicePreparation)] + public void EnrollmentFactsObserved_selfDeployingProfile_doesNotOverrideExistingMode(EnrollmentMode existing) + { + var current = EnrollmentScenarioProfile.Empty.With( + mode: existing, confidence: ProfileConfidence.Medium, + reason: "preexisting", evidenceOrdinal: 3); + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 5, + payload: new Dictionary + { + [SignalPayloadKeys.IsSelfDeployingProfile] = "true", + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(current, signal); + + Assert.Equal(existing, profile.Mode); + } + [Fact] public void EnrollmentFactsObserved_laterSignalCannotRegressJoinMode() { diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs index f8b7d034..ff06530a 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs @@ -118,6 +118,14 @@ namespace AutopilotMonitor.DecisionCore.Engine /// On SessionStarted: "true" / "false" from EnrollmentRegistryDetector.DetectHybridJoin(). public const string IsHybridJoin = "isHybridJoin"; + /// + /// On EnrollmentFactsObserved: "true" / "false" from + /// EnrollmentRegistryDetector.DetectSelfDeployingProfile() — + /// CloudAssignedOobeConfig bits 0x20|0x40, the registry-deterministic + /// self-deploying profile marker (session 320b3bf7 kiosk completion fix). + /// + public const string IsSelfDeployingProfile = "isSelfDeployingProfile"; + // --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------ // Mirrors the EnrollmentEvent fields the reducer must reconstruct for the // EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index c687d399..54b77571 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -95,10 +95,22 @@ namespace AutopilotMonitor.DecisionCore.Engine var effectsList = new List(); if (enrollmentPhase == EnrollmentPhase.AccountSetup) { - builder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); - effectsList.Add(new DecisionEffect( - DecisionEffectKind.CancelDeadline, - cancelDeadlineName: DeadlineNames.DeviceOnlyEspDetection)); + // Kiosk waiver (session 320b3bf7): on a registry-confirmed self-deploying + // profile (CloudAssignedOobeConfig 0x20|0x40 seed) this AccountSetup signal is + // the known IME false positive — the IME logs an AccountSetup phase line for + // the kioskUser0 autologon session although user ESP never runs. Cancelling + // DeviceOnlyEspDetection here was exactly what suppressed the SelfDeploying + // terminal path (436 five-hour-timeout failures platform-wide). The fact + // setting and ApplyAccountSetupObserved above stay unconditional: the entry + // remains visible in the enrollment_complete audit census, and the profile + // updater is a no-op because Mode is already SelfDeploying (non-Unknown). + if (!HasHighConfidenceSelfDeployingProfile(state)) + { + builder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + effectsList.Add(new DecisionEffect( + DecisionEffectKind.CancelDeadline, + cancelDeadlineName: DeadlineNames.DeviceOnlyEspDetection)); + } // Plan §6 Fix 10 — defensive belt-and-suspenders for the premature-AwaitingHello // bounce-back case. If Fix 7's tracker guard or Fix 8's reducer guards ever fail diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index 8204e48b..f9effb4a 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -378,7 +378,12 @@ namespace AutopilotMonitor.DecisionCore.Engine state.ScenarioProfile.Confidence == ProfileConfidence.High && state.ScenarioProfile.Mode != EnrollmentMode.Unknown && state.ScenarioProfile.Mode != EnrollmentMode.SelfDeploying; - var accountSetupEntered = state.AccountSetupEnteredUtc != null; + // Kiosk waiver (session 320b3bf7): on a registry-confirmed self-deploying + // profile the AccountSetup entry is the IME false positive and must not + // abort the deferred terminal — otherwise a hybrid+self-deploying session + // would clear the deferred flag here and park forever. Same waiver as the + // DeviceOnlyEspDetection sites (AccountSetupEntryVetoesSelfDeploying). + var accountSetupEntered = AccountSetupEntryVetoesSelfDeploying(state); if (accountSetupEntered || monotonicModeConflict) { diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs index eb24df4e..4f691b8f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -31,6 +31,45 @@ namespace AutopilotMonitor.DecisionCore.Engine public const string Ambiguous = "ambiguous"; } + /// + /// True when the scenario profile carries the registry-deterministic self-deploying + /// classification: the CloudAssignedOobeConfig 0x20|0x40 seed applied via + /// (reason + /// oobe_config_self_deploying). Pre-terminal, SelfDeploying@High can ONLY come + /// from that seed — + /// sets it exclusively inside the terminal commit of + /// . + /// + /// Session 320b3bf7 (kiosk): the IME log tracker emits a false-positive + /// EspPhaseChanged(AccountSetup) for the kioskUser0 autologon session even + /// though user ESP never runs (AccountSetup ESP registry stays all-notStarted). When + /// this predicate holds, that signal must not suppress the DeviceOnlyEspDetection + /// completion path — platform sweep 2026-07-02: 436 of 487 failed self-deploying + /// sessions (90%) died at the 5h backend timeout through exactly this suppression. + /// Deliberately Mode/Confidence-based rather than reason-string-based: + /// overwrites + /// Reason while leaving Mode/Confidence intact. + /// + /// + internal static bool HasHighConfidenceSelfDeployingProfile(DecisionState state) => + state.ScenarioProfile.Mode == EnrollmentMode.SelfDeploying + && state.ScenarioProfile.Confidence == ProfileConfidence.High; + + /// + /// The AccountSetup-entered fact blocks the SelfDeploying path (short-circuits the + /// deadline arm, trips race guard B) — EXCEPT when the profile is registry-confirmed + /// self-deploying and user ESP never made real progress. In that combination the + /// AccountSetup entry is the known IME false positive (see + /// ) and must not veto completion. + /// A genuine re-enables + /// the veto: if a real user ESP succeeded on a self-deploying-bits session (anomaly), + /// the engine falls back to the Classic completion path. + /// + private static bool AccountSetupEntryVetoesSelfDeploying(DecisionState state) => + state.AccountSetupEnteredUtc != null + && !(HasHighConfidenceSelfDeployingProfile(state) + && state.AccountSetupProvisioningSucceededUtc == null); + /// /// Handle . /// @@ -83,7 +122,12 @@ namespace AutopilotMonitor.DecisionCore.Engine // 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) + // + // Kiosk waiver (session 320b3bf7): on a registry-confirmed self-deploying profile + // the AccountSetup entry is the IME false positive — fall through to step 4 and + // arm the deadline anyway, unless user ESP genuinely progressed + // (AccountSetupEntryVetoesSelfDeploying). + if (AccountSetupEntryVetoesSelfDeploying(state)) { var classicState = builder.Build(); var classicTransition = BuildTakenTransition( @@ -231,7 +275,12 @@ namespace AutopilotMonitor.DecisionCore.Engine // --- 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) + // + // Kiosk waiver (session 320b3bf7): on a registry-confirmed self-deploying profile + // the AccountSetup entry is the IME false positive and does not veto the terminal + // classification — unless user ESP genuinely progressed + // (AccountSetupEntryVetoesSelfDeploying). + if (AccountSetupEntryVetoesSelfDeploying(state)) { var raceBState = state.ToBuilder() .WithStepIndex(state.StepIndex + 1) diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs index 1bd59a06..55373888 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs @@ -26,7 +26,10 @@ namespace AutopilotMonitor.DecisionCore.State /// signal. Payload keys: (literal /// "v1" / "v2" from EnrollmentRegistryDetector.DetectEnrollmentType), /// (boolean from - /// EnrollmentRegistryDetector.DetectHybridJoin). + /// EnrollmentRegistryDetector.DetectHybridJoin), + /// (boolean from + /// EnrollmentRegistryDetector.DetectSelfDeployingProfile — seeds + /// Mode=SelfDeploying at High confidence, session 320b3bf7 kiosk fix). /// /// V2 race-fix (10c8e0bf debrief, 2026-04-26) — replaces the profile-seeding side /// of , which lived inside the Stage-anchored @@ -92,6 +95,30 @@ namespace AutopilotMonitor.DecisionCore.State } } + // Self-deploying profile marker (session 320b3bf7 kiosk fix): the agent read + // CloudAssignedOobeConfig bits 0x20|0x40 from the Autopilot policy cache — + // validated platform-wide as exclusive to self-deploying/kiosk profiles + // (2026-07-02 sweep: 1197/8556 sessions, zero user-driven false positives). + // High confidence because the marker is registry-deterministic, unlike the + // behavioural SelfDeploying inference (5-min DeviceOnlyEspDetection deadline). + // + // Gate on the LOCAL mode, not current.Mode: a "v2" enrollmentType in the SAME + // payload already promoted the local variable to DevicePreparation above and + // must win (WDP has its own completion path). An existing non-Unknown Mode on + // `current` is covered too (local mode is initialized from it), preserving the + // documented set-once/monotonic contract — including idempotency on repeat + // posts (Mode is already SelfDeploying → block skipped → no change). + if (signal.Payload.TryGetValue(SignalPayloadKeys.IsSelfDeployingProfile, out var rawSelfDeploying) + && bool.TryParse(rawSelfDeploying, out var isSelfDeploying) + && isSelfDeploying + && mode == EnrollmentMode.Unknown) + { + mode = EnrollmentMode.SelfDeploying; + confidence = Max(confidence, ProfileConfidence.High); + reason = "oobe_config_self_deploying"; + changed = true; + } + if (signal.Payload.TryGetValue(SignalPayloadKeys.IsHybridJoin, out var rawHybrid) && bool.TryParse(rawHybrid, out var isHybrid) && current.JoinMode == EnrollmentJoinMode.Unknown) diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs index 987c4fe3..d18bbb72 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs @@ -93,6 +93,15 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsHybridJoin { get; set; } + /// + /// Whether the Autopilot profile carries the self-deploying/kiosk OOBE marker + /// (CloudAssignedOobeConfig bits 0x20|0x40; validated platform-wide 2026-07-02 as + /// exclusive to self-deploying profiles). Display/filter metadata only — the + /// DecisionCore behavioural gate consumes the EnrollmentFactsObserved signal, not + /// this flag. + /// + public bool IsSelfDeployingProfile { get; set; } + public SessionRegistration() { SessionId = Guid.NewGuid().ToString(); diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-bits-whiteglove-reclassified-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-bits-whiteglove-reclassified-v1.jsonl new file mode 100644 index 00000000..6434a1bf --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-bits-whiteglove-reclassified-v1.jsonl @@ -0,0 +1,14 @@ +# Scenario: OobeConfig self-deploying bits present, but the WhiteGlove sealing classifier +# confirms Part 1 before the DeviceOnlyEspDetection deadline fires. ApplyWhiteGloveSealingConfirmed +# sets Mode=WhiteGlove@High unconditionally (WG wins over the seed), the Confirmed verdict +# terminates the session as WhiteGloveSealed and clears all deadlines — the late DeviceOnly +# fire must dead-end via the after-terminal dispatch, NOT complete as SelfDeploying. +# (Platform sweep found 2 preProv sessions carrying the bits — this pins the defensive order.) +# Expected: Stage=WhiteGloveSealed, Outcome=WhiteGlovePart1Sealed, Mode=WhiteGlove@High; +# the final DeadlineFired (ordinal 5) is NOT taken. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:sd-bits-wg:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:01Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"OobeConfig self-deploying marker present"},"Payload":{"enrollmentType":"v1","isHybridJoin":"false","isSelfDeployingProfile":"true"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:01:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 DeviceSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete \u2014 arms deadline at 09:08","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"ClassifierVerdictIssued","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:05:00Z","SourceOrigin":"EffectRunner","Evidence":{"Kind":"Derived","Identifier":"classifier:whiteglove-sealing","Summary":"WhiteGlove sealing confirmed \u2014 technician flow, seals Part 1 and clears deadlines","DerivationInputs":{"source":"fixture"}},"Payload":{"classifier":"whiteglove-sealing","level":"Confirmed","score":"100","reason":"sealing_pattern_matched","inputHash":"fixture-wg-1"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:08:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:post-seal","Summary":"Stale fire after WhiteGloveSealed terminal \u2014 must dead-end, never SelfDeploying-complete."},"Payload":{"deadline":"device_only_esp_detection"}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-accountsetup-in-window-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-accountsetup-in-window-v1.jsonl new file mode 100644 index 00000000..73dee52c --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-accountsetup-in-window-v1.jsonl @@ -0,0 +1,14 @@ +# Scenario: SelfDeploying kiosk — IME AccountSetup false positive lands INSIDE the 5-min window. +# Reverse ordering of selfdeploying-kiosk-ime-accountsetup-v1: DeviceSetupProvisioningComplete +# arms the DeviceOnlyEspDetection deadline FIRST, then the false-positive AccountSetup arrives. +# Pre-fix, HandleEspPhaseChangedV1 cancelled the armed deadline here (the losing side of the +# race). With the kiosk waiver the cancel is skipped for a registry-confirmed self-deploying +# profile and the deadline still fires to terminal. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, Mode=SelfDeploying@High, +# DeviceOnlyDeployment=Confirmed(device_only) (no desktop/user facts). +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:kiosk-in-window:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:01Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"OobeConfig self-deploying marker present"},"Payload":{"enrollmentType":"v1","isHybridJoin":"false","isSelfDeployingProfile":"true"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:01:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 DeviceSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete \u2014 arms deadline at 09:08","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:04:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 AccountSetup (kioskUser0 FALSE POSITIVE inside the armed window)","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:08:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"Deadline survived the AccountSetup false positive (kiosk waiver) and fires to terminal."},"Payload":{"deadline":"device_only_esp_detection"}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-hybrid-rj-deferred-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-hybrid-rj-deferred-v1.jsonl new file mode 100644 index 00000000..5f4f22f3 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-hybrid-rj-deferred-v1.jsonl @@ -0,0 +1,17 @@ +# Scenario: Self-deploying kiosk with RealmJoin (hybrid) — the deferred-completion release +# must honour the kiosk waiver. Sequence: seed → RJ detected (gate closes) → DeviceSetup → +# false-positive AccountSetup → DeviceSetupProvisioningComplete arms deadline (waiver) → +# deadline fires with RJ gate closed → SelfDeployingDeferredCompletion set → RJ resolves → +# CompleteIfDeferredOrBookkeep re-check: the AccountSetup entry must NOT demote the deferred +# terminal (pre-fix it cleared the flag and the session parked forever). +# (Sweep found 0 hybrid+SD-bits sessions; this pins the defensive path.) +# Expected: Stage=Completed, Outcome=EnrollmentComplete, Mode=SelfDeploying@High, +# deferred flag consumed, RJ resolved fact present. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:kiosk-rj-deferred:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:01Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"OobeConfig self-deploying marker present (hybrid variant)"},"Payload":{"enrollmentType":"v1","isHybridJoin":"true","isSelfDeployingProfile":"true"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"RealmJoinDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:30Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-watcher-v1","Summary":"RealmJoin deployment detected (phase 100) \u2014 completion gate closes","DerivationInputs":{"source":"fixture"}},"Payload":{"deploymentPhase":"100"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:01:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 DeviceSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 AccountSetup (kioskUser0 FALSE POSITIVE)","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete \u2014 kiosk waiver arms deadline at 09:08 despite AccountSetup entry","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:08:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"Guards pass (kiosk waiver), RJ gate closed \u2192 SelfDeployingDeferredCompletion set"},"Payload":{"deadline":"device_only_esp_detection"}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"RealmJoinResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:10:00Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-watcher-v1","Summary":"RealmJoin resolved (phase 110) \u2014 deferred SelfDeploying completion releases","DerivationInputs":{"source":"fixture"}},"Payload":{"deploymentPhase":"110"}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-ime-accountsetup-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-ime-accountsetup-v1.jsonl new file mode 100644 index 00000000..716f1be2 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-ime-accountsetup-v1.jsonl @@ -0,0 +1,19 @@ +# Scenario: SelfDeploying kiosk with IME AccountSetup false positive (session 320b3bf7 shape). +# Real-world failure: the IME logs an AccountSetup phase line for the kioskUser0 autologon +# session although user ESP never runs (AccountSetup ESP registry stays all-notStarted). +# Pre-fix, that EspPhaseChanged(AccountSetup) cancelled/blocked the DeviceOnlyEspDetection +# deadline and the session idled to the 5h backend timeout (436 sessions platform-wide). +# The EnrollmentFactsObserved seed (CloudAssignedOobeConfig 0x20|0x40 → isSelfDeployingProfile) +# classifies Mode=SelfDeploying@High up front; the kiosk waiver keeps the deadline path alive. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, Mode=SelfDeploying@High +# (reason=selfdeploying_deadline_confirmed after terminal), AccountSetupEnteredUtc +# retained for audit, DeviceOnlyDeployment=Confirmed(user_present) (desktop seen). +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:kiosk-ime-accountsetup:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:01Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"Registry facts incl. OobeConfig self-deploying marker (0x5FE)"},"Payload":{"enrollmentType":"v1","isHybridJoin":"false","isSelfDeployingProfile":"true"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"HelloPolicyDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:02Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"hello-policy-detector-v1","Summary":"WHfB policy enabled via CSP (device-scoped) \u2014 kioskUser0 will never enroll Hello","DerivationInputs":{"source":"fixture"}},"Payload":{"helloEnabled":"true","policySource":"csp"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 DeviceSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:14:08Z","SourceOrigin":"ShellCoreWatcher","Evidence":{"Kind":"Raw","Identifier":"shell-core-62407","Summary":"CommercialOOBE_ESPProgress_Page_Exiting errorCode=0"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:14:19Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 AccountSetup (kioskUser0 autologon FALSE POSITIVE \u2014 user ESP never runs)","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:14:34Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete (30s all-subcategories fallback)","DerivationInputs":{"fallbackReason":"all_subcategories_succeeded_category_unresolved"}}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:17:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-v1","Summary":"explorer.exe under kioskUser0 autologon session","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:19:34Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"5-min window elapsed \u2014 kiosk waiver keeps race guard B open, terminal SelfDeploying."},"Payload":{"deadline":"device_only_esp_detection"}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-reboot-replay-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-reboot-replay-v1.jsonl new file mode 100644 index 00000000..b9104809 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-reboot-replay-v1.jsonl @@ -0,0 +1,18 @@ +# Scenario: SelfDeploying kiosk — reboot noise, duplicate desktop arrivals, stale deadline fire. +# Session 320b3bf7 rebooted 8 times while parked; each restart re-posted desktop_arrived and +# could surface stale DeadlineFired signals from pre-reboot deadline incarnations. Guards must +# absorb the noise: guard C (DueAtUtc mismatch) dead-ends the stale fire WITHOUT cancelling the +# armed deadline, the real fire completes exactly once, and a post-terminal fire dead-ends via +# the after-terminal dispatch. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, Mode=SelfDeploying@High; the stale +# fire (ordinal 6) and the post-terminal fire (ordinal 9) are NOT taken. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:kiosk-reboot-replay:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:01Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"OobeConfig self-deploying marker present"},"Payload":{"enrollmentType":"v1","isHybridJoin":"false","isSelfDeployingProfile":"true"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:01:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 DeviceSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete \u2014 arms deadline at 09:08","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:05:00Z","SourceOrigin":"BootAnchor","Evidence":{"Kind":"Derived","Identifier":"boot-anchor-v1","Summary":"Prior agent run terminated by reboot (kiosk restart)","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:06:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-v1","Summary":"explorer.exe under kioskUser0 (post-reboot autologon)","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:06:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:stale-fire","Summary":"Stale pre-reboot fire \u2014 OccurredAtUtc mismatches the armed DueAtUtc (09:08), guard C dead-ends WITHOUT cancelling."},"Payload":{"deadline":"device_only_esp_detection"}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:07:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-v1","Summary":"Duplicate desktop arrival after agent restart \u2014 DesktopArrivedUtc is set-once","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:08:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"Real fire at the armed DueAtUtc \u2014 terminal SelfDeploying."},"Payload":{"deadline":"device_only_esp_detection"}} +{"SessionSignalOrdinal":9,"SessionTraceOrdinal":9,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:08:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:post-terminal","Summary":"Late duplicate fire after terminal \u2014 after-terminal dispatch dead-ends it."},"Payload":{"deadline":"device_only_esp_detection"}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-user-esp-progress-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-user-esp-progress-v1.jsonl new file mode 100644 index 00000000..8f9395de --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-kiosk-user-esp-progress-v1.jsonl @@ -0,0 +1,15 @@ +# Scenario: OobeConfig self-deploying bits present, but user ESP GENUINELY progresses +# (AccountSetupProvisioningComplete fires). The kiosk waiver must switch OFF: a real +# AccountSetup provisioning success re-enables the AccountSetup veto, so +# DeviceSetupProvisioningComplete takes the Classic short-circuit and the DeviceOnly +# deadline is never armed — the session continues on the Classic completion path. +# (Anomaly guard: 0 such sessions observed in the sweep, defended anyway.) +# Expected: Stage=EspAccountSetup (non-terminal), no DeviceOnlyEspDetection deadline, +# Mode stays SelfDeploying@High (profile is monotonic — only the completion +# routing falls back to Classic). +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:kiosk-user-esp:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:00:01Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"OobeConfig self-deploying marker present"},"Payload":{"enrollmentType":"v1","isHybridJoin":"false","isSelfDeployingProfile":"true"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:01:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 DeviceSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"ime-log-tracker-v1","Summary":"IME log phase transition \u2192 AccountSetup","DerivationInputs":{"source":"fixture"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"AccountSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"AccountSetup category genuinely succeeded \u2014 user ESP made real progress","DerivationInputs":{"source":"fixture"}}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-07-02T09:04:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup resolved late \u2014 veto re-enabled by the real AccountSetup success, Classic short-circuit, no deadline arm","DerivationInputs":{"source":"fixture"}}}