mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
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:
@@ -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"]!;
|
||||
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-11
@@ -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()
|
||||
{
|
||||
|
||||
+58
-6
@@ -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"}}
|
||||
|
||||
Reference in New Issue
Block a user