fix(decisioncore): defang SelfDeploying terminal via 5-min deadline + UI phase coverage

Session 88a53223-9795-4188-8352-7df9f0af9bb7 (v1 Classic UserDriven, AAD
Joined, SkipUserStatusPage=false) was wrongly classified as mode=SelfDeploying
after the ProvisioningStatusTracker 30-s fallback fired DeviceSetupProvisioningComplete
before Windows transitioned DeviceSetup -> AccountSetup. The signal alone was
terminal in the old reducer; the absence of AccountSetupEnteredUtc at signal
time was sufficient to commit terminal SelfDeploying.

New rule: DeviceSetupProvisioningComplete is no longer terminal. It records a
new DeviceSetupResolvedUtc anchor and (cancel-then-)arms the DeviceOnlyEspDetection
deadline for 5 min. The deadline-fired handler is the sole SelfDeploying-terminal
entry point and re-checks six guards (stale: no-anchor / not-armed / DueAtUtc-mismatch;
race: Stage-terminal / AccountSetup-entered / monotonic Mode conflict) before
classifying. Any dead-end after the DueAtUtc match state-side CancelDeadline()s
the past-due deadline so snapshot reload doesn't re-arm via the scheduler.

RealmJoin-deferred release (CompleteIfDeferredOrBookkeep) re-checks the same
guards, resets DeviceOnlyDeployment hypothesis to Unknown on bailout, and
operates on preparedBuilder.* (not state.*) so WithResolved / WithTimeoutOutcome
facts survive the bailout.

UI phase coverage closed: phase_transition(FinalizingSetup) and
phase_transition(Complete) emitted at every terminal-success site (Classic
FinalizingGrace, SelfDeploying deadline-terminal, AdminPreemption-Succeeded,
RJ-deferred completion) so the Web timeline opens Finalizing + Complete bars
universally - was missing entirely for SelfDeploying / AdminPreemption paths.

Other changes:
- Removed ApplyDeviceSetupProvisioningComplete (dead code under defang), added
  ApplySelfDeployingDeadlineConfirmed with monotonic guard.
- New RealmJoinFacts.ClearSelfDeployingDeferred() for the RJ-bailout path.
- DecisionState schema bumped v3 -> v4 with additive nullable
  DeviceSetupResolvedUtc field. StateSerializer v3-deserialize-compat verified.
- DeviceSetupProvisioningComplete added to DecisionStateSignalCensus so the
  enrollment_complete audit trail surfaces the anchor.

Test coverage:
- New SelfDeployingDeadlineTerminalTests.cs (12 scenarios): happy path, all
  six guards including snapshot-migration rollout race + DueAtUtc cancel-rearm
  race, replay idempotency, audit-census wiring, RJ-deferred release races
  (AccountSetup-arrives / monotonic / Timeout-variant).
- Updated 9 existing test files for new semantics; 2 fixtures now drive
  DeadlineFired terminal explicitly.
- ReplayHarness + ClassifierAwareReplayHarness set AgentBootUtc to the first
  signal's OccurredAtUtc so fixture scenarios run in their own time-bubble
  (replay-safety floor still tested directly by BootAnchorDeadlineFloorTests).

Tests: DecisionCore 345, V2.Core 1135, Backend 1937 - all green.

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