mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
feat(agent): complete self-deploying/kiosk sessions despite IME AccountSetup false positive
Kiosk devices (self-deploying profile) never completed: the IME logs an AccountSetup phase line for the kioskUser0 autologon session although user ESP never runs, which cancelled/blocked the DeviceOnlyEspDetection deadline - the sole SelfDeploying terminal path - and the session idled to the 5h backend timeout (reference session 320b3bf7; 436 false Failed platform-wide, 90% of all self-deploying failures). Platform sweep (8983 sessions / 63 tenants): CloudAssignedOobeConfig bits 0x20|0x40 are exclusive to self-deploying/kiosk/shared profiles (0 of 6785 user-driven sessions carry them, bits always co-occur). The fix seeds Mode=SelfDeploying@High from that registry marker via EnrollmentFactsObserved and waives the AccountSetup veto at the four existing decision points (DSPC short-circuit, deadline race guard B, EspPhaseChanged cancel, RealmJoin deferred-release re-check) - only when the seeded profile holds AND user ESP made no real progress. Bits absent = byte-identical behaviour. Also registers IsSelfDeployingProfile on SessionRegistration (display metadata; backend persistence follows separately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+5
@@ -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<string, string>)payload!);
|
||||
Assert.Contains("isHybridJoin", (IDictionary<string, string>)payload);
|
||||
Assert.Contains("isSelfDeployingProfile", (IDictionary<string, string>)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]
|
||||
|
||||
+95
@@ -127,5 +127,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
var actual = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
Assert.IsType<bool>(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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["PolicyJsonCache"] = policyJson,
|
||||
};
|
||||
Assert.True(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_TopLevel_OobeConfig_PolicyJsonCache_userdriven_returns_false()
|
||||
{
|
||||
var values = new Dictionary<string, object>
|
||||
{
|
||||
["PolicyJsonCache"] = "{\"CloudAssignedOobeConfig\":1310}",
|
||||
};
|
||||
Assert.False(EnrollmentRegistryDetector.ResolveSelfDeployingFromValues(Lookup(values)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OobeConfig_absent_everywhere_returns_false()
|
||||
{
|
||||
var values = new Dictionary<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<bool>(actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// <c>CloudAssignedOobeConfig</c> 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.
|
||||
/// </summary>
|
||||
internal const int SelfDeployingOobeConfigMask = 0x60;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>CloudAssignedDomainJoinMethod</c> from the Autopilot policy cache. Returns
|
||||
/// <c>true</c> when the profile was deployed with Hybrid Azure AD Join
|
||||
@@ -88,6 +98,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>CloudAssignedOobeConfig</c> from the Autopilot policy cache and returns
|
||||
/// <c>true</c> when both self-deploying marker bits (<see cref="SelfDeployingOobeConfigMask"/>)
|
||||
/// are set. Mirrors the <see cref="DetectHybridJoin"/> top-level-value-then-
|
||||
/// <c>PolicyJsonCache</c>-JSON fallback, because the same Windows builds that omit the
|
||||
/// flat <c>CloudAssignedDomainJoinMethod</c> value also omit <c>CloudAssignedOobeConfig</c>.
|
||||
/// Returns <c>false</c> on any error — a missing/unreadable profile must degrade to
|
||||
/// today's classic behaviour, never to a false self-deploying classification.
|
||||
/// </summary>
|
||||
public static bool DetectSelfDeployingProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotPolicyCacheKey))
|
||||
{
|
||||
if (key == null) return false;
|
||||
return ResolveSelfDeployingFromValues(key.GetValue);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for <see cref="DetectSelfDeployingProfile"/>. Exposed internally
|
||||
/// so the fallback can be exercised without a real registry (same seam contract as
|
||||
/// <see cref="ResolveHybridJoinFromValues"/>).
|
||||
/// </summary>
|
||||
internal static bool ResolveSelfDeployingFromValues(Func<string, object> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the Autopilot flow based on the <c>AutopilotSettings</c> registry:
|
||||
/// <list type="bullet">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string>(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)
|
||||
{
|
||||
|
||||
+159
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
{
|
||||
[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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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]
|
||||
|
||||
+112
@@ -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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string> Payload() => new Dictionary<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(current, signal);
|
||||
|
||||
Assert.Equal(existing, profile.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_laterSignalCannotRegressJoinMode()
|
||||
{
|
||||
|
||||
@@ -118,6 +118,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <summary>On <c>SessionStarted</c>: "true" / "false" from <c>EnrollmentRegistryDetector.DetectHybridJoin()</c>.</summary>
|
||||
public const string IsHybridJoin = "isHybridJoin";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: "true" / "false" from
|
||||
/// <c>EnrollmentRegistryDetector.DetectSelfDeployingProfile()</c> —
|
||||
/// <c>CloudAssignedOobeConfig</c> bits 0x20|0x40, the registry-deterministic
|
||||
/// self-deploying profile marker (session 320b3bf7 kiosk completion fix).
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -95,10 +95,22 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var effectsList = new List<DecisionEffect>();
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,45 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
public const string Ambiguous = "ambiguous";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the scenario profile carries the registry-deterministic self-deploying
|
||||
/// classification: the <c>CloudAssignedOobeConfig</c> 0x20|0x40 seed applied via
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> (reason
|
||||
/// <c>oobe_config_self_deploying</c>). Pre-terminal, SelfDeploying@High can ONLY come
|
||||
/// from that seed — <see cref="EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed"/>
|
||||
/// sets it exclusively inside the terminal commit of
|
||||
/// <see cref="HandleDeviceOnlyEspDetectionDeadlineFired"/>.
|
||||
/// <para>
|
||||
/// Session 320b3bf7 (kiosk): the IME log tracker emits a false-positive
|
||||
/// <c>EspPhaseChanged(AccountSetup)</c> 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:
|
||||
/// <see cref="EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected"/> overwrites
|
||||
/// <c>Reason</c> while leaving Mode/Confidence intact.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool HasHighConfidenceSelfDeployingProfile(DecisionState state) =>
|
||||
state.ScenarioProfile.Mode == EnrollmentMode.SelfDeploying
|
||||
&& state.ScenarioProfile.Confidence == ProfileConfidence.High;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="HasHighConfidenceSelfDeployingProfile"/>) and must not veto completion.
|
||||
/// A genuine <see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/> 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.
|
||||
/// </summary>
|
||||
private static bool AccountSetupEntryVetoesSelfDeploying(DecisionState state) =>
|
||||
state.AccountSetupEnteredUtc != null
|
||||
&& !(HasHighConfidenceSelfDeployingProfile(state)
|
||||
&& state.AccountSetupProvisioningSucceededUtc == null);
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/>.
|
||||
/// <para>
|
||||
@@ -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)
|
||||
|
||||
@@ -26,7 +26,10 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// signal. Payload keys: <see cref="SignalPayloadKeys.EnrollmentType"/> (literal
|
||||
/// "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType</c>),
|
||||
/// <see cref="SignalPayloadKeys.IsHybridJoin"/> (boolean from
|
||||
/// <c>EnrollmentRegistryDetector.DetectHybridJoin</c>).
|
||||
/// <c>EnrollmentRegistryDetector.DetectHybridJoin</c>),
|
||||
/// <see cref="SignalPayloadKeys.IsSelfDeployingProfile"/> (boolean from
|
||||
/// <c>EnrollmentRegistryDetector.DetectSelfDeployingProfile</c> — seeds
|
||||
/// Mode=SelfDeploying at High confidence, session 320b3bf7 kiosk fix).
|
||||
/// <para>
|
||||
/// V2 race-fix (10c8e0bf debrief, 2026-04-26) — replaces the profile-seeding side
|
||||
/// of <see cref="ApplySessionStarted"/>, 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)
|
||||
|
||||
@@ -93,6 +93,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool IsHybridJoin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsSelfDeployingProfile { get; set; }
|
||||
|
||||
public SessionRegistration()
|
||||
{
|
||||
SessionId = Guid.NewGuid().ToString();
|
||||
|
||||
+14
@@ -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"}}
|
||||
+14
@@ -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"}}
|
||||
+17
@@ -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"}}
|
||||
+19
@@ -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"}}
|
||||
+18
@@ -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"}}
|
||||
+15
@@ -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"}}}
|
||||
Reference in New Issue
Block a user