v2 codex-05: typed ScenarioProfile + Observations + ClassifierOutcomes aggregates

Codex review follow-up #5 (Option 2b, clean-cut). Replaces the legacy
9-field hypothesis/fact mosaic on DecisionState with three structured
aggregates that separate concerns cleanly:

- EnrollmentScenarioProfile — derived classification (Mode, JoinMode,
  EspConfig, PreProvisioningSide, Confidence, EvidenceOrdinal, Reason)
  replacing the Hypothesis EnrollmentType + SkipUser/DeviceEsp facts.
- EnrollmentScenarioObservations — raw per-signal evidence
  (ShellCoreWhiteGloveSuccessSeen, WhiteGloveSealingPatternSeen,
  AadUserJoinWithUserObserved, SkipUserEsp, SkipDeviceEsp) with set-once
  semantics, feeding the WhiteGlove sealing classifier snapshot.
- ClassifierOutcomes — classifier verdict + anti-loop storage
  (WhiteGloveSealing, WhiteGlovePart2Completion, DeviceOnlyDeployment)
  replacing the three Hypothesis fields they used to occupy.

EnrollmentScenarioProfileUpdater holds the signal → profile mapping rules
with monotonic-confidence invariants. EspConfigDetected keeps its partial-
first-signal / second-signal-completes semantic via the observations half-
facts; the profile's EspConfig is only derived once both halves are known.

Critical Codex correction: AadUserJoinWithUserObserved is NOT a JoinMode
signal — it is a late-AADJ user-presence hard-excluder for the WhiteGlove
classifier. JoinMode comes strictly from SessionStarted.payload.isHybridJoin.

Classifier algorithm, weights, and thresholds unchanged; only snapshot
input sources, anti-loop verdict storage, and the AwaitingHello guard
migrate. The WhiteGloveSealingSnapshot fields keep their names and types,
so ComputeInputHash stays byte-stable and the anti-loop contract is
preserved across the refactor. AssemblyVersion stays 2.0.0.0.

Suites: DecisionCore 203/203 (+39 new aggregate/updater tests),
Agent.V2.Core 650/650, Functions 735/735; 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-24 15:21:11 +02:00
parent 5091d5f1ed
commit 51d57153ad
39 changed files with 1560 additions and 273 deletions
@@ -86,7 +86,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
// DeviceOnlyDeployment hypothesis should have climbed from Unknown → Strong/Confirmed. // DeviceOnlyDeployment hypothesis should have climbed from Unknown → Strong/Confirmed.
Assert.NotEqual(HypothesisLevel.Unknown, f.Orchestrator.CurrentState.DeviceOnlyDeployment.Level); Assert.NotEqual(HypothesisLevel.Unknown, f.Orchestrator.CurrentState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
f.Stop(); f.Stop();
} }
@@ -101,7 +101,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
f.PostFixture("whiteglove-inline-v1.jsonl"); f.PostFixture("whiteglove-inline-v1.jsonl");
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed)); Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.ClassifierOutcomes.WhiteGloveSealing.Level);
f.Stop(); f.Stop();
} }
@@ -116,7 +116,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
f.PostFixture("whiteglove-signal-correlated-v1.jsonl"); f.PostFixture("whiteglove-signal-correlated-v1.jsonl");
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed)); Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.ClassifierOutcomes.WhiteGloveSealing.Level);
f.Stop(); f.Stop();
} }
@@ -153,7 +153,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
// Late-AADJ rejects the WG hypothesis; session still completes via the classic path. // Late-AADJ rejects the WG hypothesis; session still completes via the classic path.
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
Assert.NotEqual(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level); Assert.NotEqual(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.ClassifierOutcomes.WhiteGloveSealing.Level);
f.Stop(); f.Stop();
} }
@@ -301,12 +301,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var state = InitialState() var state = InitialState()
.ToBuilder() .ToBuilder()
.Apply(b => b.WhiteGloveSealing = new Hypothesis( .Apply(b => b.ClassifierOutcomes = b.ClassifierOutcomes.WithWhiteGloveSealing(new Hypothesis(
level: HypothesisLevel.Strong, level: HypothesisLevel.Strong,
reason: "seed", reason: "seed",
score: 80, score: 80,
lastUpdatedUtc: At, lastUpdatedUtc: At,
lastClassifierVerdictId: Hash)) lastClassifierVerdictId: Hash)))
.Build(); .Build();
var rig = new Rig(); var rig = new Rig();
@@ -99,7 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Stage = SessionStage.Completed, Stage = SessionStage.Completed,
HelloResolvedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(5), 1), HelloResolvedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(5), 1),
DesktopArrivedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(10), 2), DesktopArrivedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(10), 2),
AadJoinedWithUser = new SignalFact<bool>(true, 3), ScenarioObservations = EnrollmentScenarioObservations.Empty.WithAadUserJoinWithUserObserved(value: true, sourceSignalOrdinal: 3),
ImeMatchedPatternId = new SignalFact<string>("esp_done", 4), ImeMatchedPatternId = new SignalFact<string>("esp_done", 4),
}.Build(); }.Build();
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Outcome = MapOutcome(terminated.Outcome, state.Stage), Outcome = MapOutcome(terminated.Outcome, state.Stage),
CompletionSource = terminated.Reason.ToString(), CompletionSource = terminated.Reason.ToString(),
HelloOutcome = state.HelloOutcome?.Value ?? "unknown", HelloOutcome = state.HelloOutcome?.Value ?? "unknown",
EnrollmentType = state.EnrollmentType?.Reason ?? state.EnrollmentType?.Level.ToString() ?? "unknown", EnrollmentType = DescribeEnrollmentType(state.ScenarioProfile),
AgentUptimeSeconds = uptimeSeconds, AgentUptimeSeconds = uptimeSeconds,
SignalsSeen = BuildSignalsSeen(state), SignalsSeen = BuildSignalsSeen(state),
AppSummary = BuildAppSummary(packageStates), AppSummary = BuildAppSummary(packageStates),
@@ -73,10 +73,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
if (state.HelloResolvedUtc != null) signals.Add("hello_resolved"); if (state.HelloResolvedUtc != null) signals.Add("hello_resolved");
if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived"); if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived");
if (state.SystemRebootUtc != null) signals.Add("system_reboot"); if (state.SystemRebootUtc != null) signals.Add("system_reboot");
if (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) signals.Add("aad_user_joined"); var obs = state.ScenarioObservations;
if (state.ShellCoreWhiteGloveSuccessSeen != null && state.ShellCoreWhiteGloveSuccessSeen.Value) if (obs.AadUserJoinWithUserObserved != null && obs.AadUserJoinWithUserObserved.Value) signals.Add("aad_user_joined");
if (obs.ShellCoreWhiteGloveSuccessSeen != null && obs.ShellCoreWhiteGloveSuccessSeen.Value)
signals.Add("whiteglove_shellcore_success"); signals.Add("whiteglove_shellcore_success");
if (state.WhiteGloveSealingPatternSeen != null && state.WhiteGloveSealingPatternSeen.Value) if (obs.WhiteGloveSealingPatternSeen != null && obs.WhiteGloveSealingPatternSeen.Value)
signals.Add("whiteglove_sealing_pattern"); signals.Add("whiteglove_sealing_pattern");
if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}"); if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}");
if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin"); if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin");
@@ -149,5 +150,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|| s == AppInstallationState.Skipped || s == AppInstallationState.Skipped
|| s == AppInstallationState.Postponed || s == AppInstallationState.Postponed
|| s == AppInstallationState.Error; || s == AppInstallationState.Error;
/// <summary>
/// Codex follow-up #5 — the final-status summary exposed the legacy
/// <c>Hypothesis.Reason ?? Level</c> string. The equivalent derivable from
/// <see cref="EnrollmentScenarioProfile"/> is the <c>Mode</c> name when known,
/// falling back to the current <see cref="EnrollmentScenarioProfile.Reason"/>
/// token, and finally to <c>"unknown"</c>.
/// </summary>
private static string DescribeEnrollmentType(EnrollmentScenarioProfile profile)
{
if (profile.Mode != EnrollmentMode.Unknown) return profile.Mode.ToString();
if (!string.IsNullOrEmpty(profile.Reason)) return profile.Reason!;
return "unknown";
}
} }
} }
@@ -1 +1 @@
296 306
@@ -1 +1 @@
1137 1143
@@ -188,7 +188,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
[SignalPayloadKeys.SkipDeviceEsp] = "false", [SignalPayloadKeys.SkipDeviceEsp] = "false",
}); });
var afterConfig = engine.Reduce(seed, espConfig).NewState; var afterConfig = engine.Reduce(seed, espConfig).NewState;
Assert.True(afterConfig.SkipUserEsp!.Value); Assert.True(afterConfig.ScenarioObservations.SkipUserEsp!.Value);
Assert.Equal(EspConfig.DeviceEspOnly, afterConfig.ScenarioProfile.EspConfig);
// Step 2 — EspPhaseChanged(FinalizingSetup) from EspAndHelloTrackerAdapter. // Step 2 — EspPhaseChanged(FinalizingSetup) from EspAndHelloTrackerAdapter.
var finalizing = MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 2); var finalizing = MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 2);
@@ -231,7 +232,20 @@ namespace AutopilotMonitor.DecisionCore.Tests
.WithStage(stage) .WithStage(stage)
.WithStepIndex(2) .WithStepIndex(2)
.WithLastAppliedSignalOrdinal(1); .WithLastAppliedSignalOrdinal(1);
if (skipUser.HasValue) builder.SkipUserEsp = new SignalFact<bool>(skipUser.Value, 0); // Codex follow-up #5: the guard reads from Profile.EspConfig now. SkipUser=true
// without a skipDevice observation would leave EspConfig=Unknown — so tests that
// pre-seed "SkipUser=true" must also pre-seed skipDevice=false to land at
// DeviceEspOnly (the semantic equivalent of the legacy "SkipUserEsp.Value == true").
if (skipUser.HasValue)
{
builder.ScenarioObservations = builder.ScenarioObservations
.WithSkipUserEsp(skipUser.Value, sourceSignalOrdinal: 0)
.WithSkipDeviceEsp(value: false, sourceSignalOrdinal: 0);
builder.ScenarioProfile = builder.ScenarioProfile.With(
espConfig: EnrollmentScenarioProfileUpdater.DeriveEspConfig(skipUser.Value, false),
confidence: ProfileConfidence.Medium,
evidenceOrdinal: 0);
}
if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(Fixed.AddMinutes(-1), 1); if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(Fixed.AddMinutes(-1), 1);
return builder.Build(); return builder.Build();
} }
@@ -48,9 +48,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10)); var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10));
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage); Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType); // Diagnostic signals must not change any of the typed aggregates.
Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing); Assert.Same(midFlight.ScenarioProfile, step.NewState.ScenarioProfile);
Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment); Assert.Same(midFlight.ScenarioObservations, step.NewState.ScenarioObservations);
Assert.Same(midFlight.ClassifierOutcomes, step.NewState.ClassifierOutcomes);
} }
[Theory] [Theory]
@@ -9,15 +9,17 @@ using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests namespace AutopilotMonitor.DecisionCore.Tests
{ {
/// <summary> /// <summary>
/// Plan §6 Fix 9 — <see cref="DecisionSignalKind.EspConfigDetected"/> populates the /// Plan §6 Fix 9 + Codex follow-up #5 — <see cref="DecisionSignalKind.EspConfigDetected"/>
/// <see cref="DecisionState.SkipUserEsp"/> / <see cref="DecisionState.SkipDeviceEsp"/> /// populates <see cref="EnrollmentScenarioObservations.SkipUserEsp"/> /
/// facts. Set-once semantics: later signals with the same or different payload are no-ops /// <see cref="EnrollmentScenarioObservations.SkipDeviceEsp"/> raw observations and, once
/// once a fact is present (monotonic, analogous to <c>DeviceSetupEnteredUtc</c>). /// BOTH halves are known, derives <see cref="EnrollmentScenarioProfile.EspConfig"/>.
/// Set-once semantics: later signals with the same or different payload are no-ops
/// once an observation is present (monotonic).
/// </summary> /// </summary>
public sealed class EspConfigDetectedHandlerTests public sealed class EspConfigDetectedHandlerTests
{ {
[Fact] [Fact]
public void EspConfigDetected_populates_SkipUserEsp_and_SkipDeviceEsp_from_payload() public void EspConfigDetected_populates_observations_and_derives_EspConfig()
{ {
var engine = new DecisionEngine(); var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t"); var state = DecisionState.CreateInitial("s", "t");
@@ -27,13 +29,17 @@ namespace AutopilotMonitor.DecisionCore.Tests
skipUser: "true", skipUser: "true",
skipDevice: "false")); skipDevice: "false"));
Assert.NotNull(step.NewState.SkipUserEsp); var obs = step.NewState.ScenarioObservations;
Assert.True(step.NewState.SkipUserEsp!.Value); Assert.NotNull(obs.SkipUserEsp);
Assert.Equal(3, step.NewState.SkipUserEsp!.SourceSignalOrdinal); Assert.True(obs.SkipUserEsp!.Value);
Assert.Equal(3, obs.SkipUserEsp!.SourceSignalOrdinal);
Assert.NotNull(step.NewState.SkipDeviceEsp); Assert.NotNull(obs.SkipDeviceEsp);
Assert.False(step.NewState.SkipDeviceEsp!.Value); Assert.False(obs.SkipDeviceEsp!.Value);
Assert.Equal(3, step.NewState.SkipDeviceEsp!.SourceSignalOrdinal); Assert.Equal(3, obs.SkipDeviceEsp!.SourceSignalOrdinal);
// Both halves observed → Profile.EspConfig derived.
Assert.Equal(EspConfig.DeviceEspOnly, step.NewState.ScenarioProfile.EspConfig);
Assert.True(step.Transition.Taken); Assert.True(step.Transition.Taken);
Assert.Null(step.Transition.DeadEndReason); Assert.Null(step.Transition.DeadEndReason);
@@ -60,6 +66,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage); Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
Assert.Equal(6, step.NewState.StepIndex); Assert.Equal(6, step.NewState.StepIndex);
Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal); Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal);
Assert.Equal(EspConfig.FullEsp, step.NewState.ScenarioProfile.EspConfig);
} }
[Fact] [Fact]
@@ -79,10 +86,14 @@ namespace AutopilotMonitor.DecisionCore.Tests
skipUser: "true", skipUser: "true",
skipDevice: "true")); skipDevice: "true"));
Assert.False(step2.NewState.SkipUserEsp!.Value); var obs = step2.NewState.ScenarioObservations;
Assert.Equal(1, step2.NewState.SkipUserEsp!.SourceSignalOrdinal); Assert.False(obs.SkipUserEsp!.Value);
Assert.False(step2.NewState.SkipDeviceEsp!.Value); Assert.Equal(1, obs.SkipUserEsp!.SourceSignalOrdinal);
Assert.Equal(1, step2.NewState.SkipDeviceEsp!.SourceSignalOrdinal); Assert.False(obs.SkipDeviceEsp!.Value);
Assert.Equal(1, obs.SkipDeviceEsp!.SourceSignalOrdinal);
// Profile.EspConfig was derived on the first signal and not regressed.
Assert.Equal(EspConfig.FullEsp, step2.NewState.ScenarioProfile.EspConfig);
// The second signal still bumps bookkeeping (taken transition, no effects). // The second signal still bumps bookkeeping (taken transition, no effects).
Assert.True(step2.Transition.Taken); Assert.True(step2.Transition.Taken);
@@ -91,7 +102,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
} }
[Fact] [Fact]
public void EspConfigDetected_missingKeys_leavesFactsNull() public void EspConfigDetected_missingKeys_leavesFactsNull_andProfileUnknown()
{ {
var engine = new DecisionEngine(); var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t"); var state = DecisionState.CreateInitial("s", "t");
@@ -101,14 +112,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
skipUser: null, skipUser: null,
skipDevice: null)); skipDevice: null));
Assert.Null(step.NewState.SkipUserEsp); var obs = step.NewState.ScenarioObservations;
Assert.Null(step.NewState.SkipDeviceEsp); Assert.Null(obs.SkipUserEsp);
Assert.Null(obs.SkipDeviceEsp);
Assert.Equal(EspConfig.Unknown, step.NewState.ScenarioProfile.EspConfig);
Assert.True(step.Transition.Taken); Assert.True(step.Transition.Taken);
Assert.Equal(1, step.NewState.StepIndex); Assert.Equal(1, step.NewState.StepIndex);
} }
[Fact] [Fact]
public void EspConfigDetected_partialPayload_setsOnlyKnownFact_leavesOtherNull() public void EspConfigDetected_partialPayload_setsOnlyKnownFact_profileStaysUnknown()
{ {
// Realistic: registry has SkipUserStatusPage but SkipDeviceStatusPage key missing. // Realistic: registry has SkipUserStatusPage but SkipDeviceStatusPage key missing.
var engine = new DecisionEngine(); var engine = new DecisionEngine();
@@ -119,16 +132,20 @@ namespace AutopilotMonitor.DecisionCore.Tests
skipUser: "true", skipUser: "true",
skipDevice: null)); skipDevice: null));
Assert.NotNull(step.NewState.SkipUserEsp); var obs = step.NewState.ScenarioObservations;
Assert.True(step.NewState.SkipUserEsp!.Value); Assert.NotNull(obs.SkipUserEsp);
Assert.Null(step.NewState.SkipDeviceEsp); Assert.True(obs.SkipUserEsp!.Value);
Assert.Null(obs.SkipDeviceEsp);
// Only one half observed → Profile.EspConfig cannot be derived yet.
Assert.Equal(EspConfig.Unknown, step.NewState.ScenarioProfile.EspConfig);
} }
[Fact] [Fact]
public void EspConfigDetected_partialFirstSignal_secondSignalCanFillMissingFact() public void EspConfigDetected_partialFirstSignal_secondSignalCompletes_derivesEspConfig()
{ {
// First signal sets only skipUser; second signal (different ordinal) can still fill // First signal sets only skipUser; second signal (different ordinal) can still fill
// in skipDevice because set-once is per-fact, not per-signal. // in skipDevice because set-once is per-observation, not per-signal. Profile.EspConfig
// is derived once both halves are known.
var engine = new DecisionEngine(); var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t"); var state = DecisionState.CreateInitial("s", "t");
@@ -142,13 +159,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
skipUser: null, skipUser: null,
skipDevice: "false")); skipDevice: "false"));
Assert.NotNull(step2.NewState.SkipUserEsp); var obs = step2.NewState.ScenarioObservations;
Assert.True(step2.NewState.SkipUserEsp!.Value); Assert.NotNull(obs.SkipUserEsp);
Assert.Equal(1, step2.NewState.SkipUserEsp!.SourceSignalOrdinal); Assert.True(obs.SkipUserEsp!.Value);
Assert.Equal(1, obs.SkipUserEsp!.SourceSignalOrdinal);
Assert.NotNull(step2.NewState.SkipDeviceEsp); Assert.NotNull(obs.SkipDeviceEsp);
Assert.False(step2.NewState.SkipDeviceEsp!.Value); Assert.False(obs.SkipDeviceEsp!.Value);
Assert.Equal(2, step2.NewState.SkipDeviceEsp!.SourceSignalOrdinal); Assert.Equal(2, obs.SkipDeviceEsp!.SourceSignalOrdinal);
Assert.Equal(EspConfig.DeviceEspOnly, step2.NewState.ScenarioProfile.EspConfig);
} }
[Fact] [Fact]
@@ -181,21 +201,25 @@ namespace AutopilotMonitor.DecisionCore.Tests
var json = StateSerializer.Serialize(populated); var json = StateSerializer.Serialize(populated);
var roundtripped = StateSerializer.Deserialize(json); var roundtripped = StateSerializer.Deserialize(json);
Assert.NotNull(roundtripped.SkipUserEsp); var obs = roundtripped.ScenarioObservations;
Assert.True(roundtripped.SkipUserEsp!.Value); Assert.NotNull(obs.SkipUserEsp);
Assert.Equal(7, roundtripped.SkipUserEsp!.SourceSignalOrdinal); Assert.True(obs.SkipUserEsp!.Value);
Assert.Equal(7, obs.SkipUserEsp!.SourceSignalOrdinal);
Assert.NotNull(roundtripped.SkipDeviceEsp); Assert.NotNull(obs.SkipDeviceEsp);
Assert.False(roundtripped.SkipDeviceEsp!.Value); Assert.False(obs.SkipDeviceEsp!.Value);
Assert.Equal(7, roundtripped.SkipDeviceEsp!.SourceSignalOrdinal); Assert.Equal(7, obs.SkipDeviceEsp!.SourceSignalOrdinal);
Assert.Equal(EspConfig.DeviceEspOnly, roundtripped.ScenarioProfile.EspConfig);
} }
[Fact] [Fact]
public void EspConfigDetected_initialState_facts_areNull() public void EspConfigDetected_initialState_observations_areNull()
{ {
var state = DecisionState.CreateInitial("s", "t"); var state = DecisionState.CreateInitial("s", "t");
Assert.Null(state.SkipUserEsp); Assert.Null(state.ScenarioObservations.SkipUserEsp);
Assert.Null(state.SkipDeviceEsp); Assert.Null(state.ScenarioObservations.SkipDeviceEsp);
Assert.Equal(EspConfig.Unknown, state.ScenarioProfile.EspConfig);
} }
private static DecisionSignal MakeSignal( private static DecisionSignal MakeSignal(
@@ -180,8 +180,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Harness
state.Outcome?.ToString() ?? "null", state.Outcome?.ToString() ?? "null",
state.StepIndex.ToString(), state.StepIndex.ToString(),
state.LastAppliedSignalOrdinal.ToString(), state.LastAppliedSignalOrdinal.ToString(),
state.WhiteGloveSealing.Level.ToString(), state.ClassifierOutcomes.WhiteGloveSealing.Level.ToString(),
state.WhiteGloveSealing.Score.ToString()); state.ClassifierOutcomes.WhiteGloveSealing.Score.ToString());
using (var sha = System.Security.Cryptography.SHA256.Create()) using (var sha = System.Security.Cryptography.SHA256.Create())
{ {
@@ -79,9 +79,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
var step = engine.Reduce(midFlight, MakeSignal(ordinal: 10, payload: MinimalPayload())); var step = engine.Reduce(midFlight, MakeSignal(ordinal: 10, payload: MinimalPayload()));
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage); Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType); // Informational signals must not change any of the typed aggregates.
Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing); Assert.Same(midFlight.ScenarioProfile, step.NewState.ScenarioProfile);
Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment); Assert.Same(midFlight.ScenarioObservations, step.NewState.ScenarioObservations);
Assert.Same(midFlight.ClassifierOutcomes, step.NewState.ClassifierOutcomes);
} }
[Fact] [Fact]
@@ -30,9 +30,12 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(EnrollmentPhase.AccountSetup, result.FinalState.CurrentEnrollmentPhase!.Value); Assert.Equal(EnrollmentPhase.AccountSetup, result.FinalState.CurrentEnrollmentPhase!.Value);
Assert.Empty(result.FinalState.Deadlines); Assert.Empty(result.FinalState.Deadlines);
// EnrollmentType should reach Strong after ImeUserSessionCompleted. // Codex follow-up #5 — the legacy Hypothesis EnrollmentType was absorbed into
Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); // ScenarioProfile. Mode=Classic @ High confidence is the equivalent of the old
Assert.Equal("ime_user_session_completed", result.FinalState.EnrollmentType.Reason); // "Strong" level after ImeUserSessionCompleted.
Assert.Equal(EnrollmentMode.Classic, result.FinalState.ScenarioProfile.Mode);
Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence);
Assert.Equal("ime_user_session_completed", result.FinalState.ScenarioProfile.Reason);
// 7 fixture signals + 1 auto-fired FinalizingGrace deadline (plan §5 Fix 6 — the // 7 fixture signals + 1 auto-fired FinalizingGrace deadline (plan §5 Fix 6 — the
// reducer now parks in Finalizing after both prerequisites resolve; the harness // reducer now parks in Finalizing after both prerequisites resolve; the harness
@@ -92,19 +95,20 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
// AadJoinedWithUser fact is recorded from the Late-AADJ signal. // AadUserJoinWithUserObserved fact is recorded from the Late-AADJ signal.
Assert.NotNull(result.FinalState.AadJoinedWithUser); Assert.NotNull(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved);
Assert.True(result.FinalState.AadJoinedWithUser!.Value); Assert.True(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved!.Value);
// EnrollmentType reason carries the late-AADJ annotation (set by the handler), // Profile reason carries the last-strengthening reason token — after the IME
// and then advances to "ime_user_session_completed" on the IME signal. // signal that is "ime_user_session_completed", not the prior late-AADJ annotation.
Assert.Equal("ime_user_session_completed", result.FinalState.EnrollmentType.Reason); Assert.Equal("ime_user_session_completed", result.FinalState.ScenarioProfile.Reason);
// Hypothesis chain: // Profile chain:
// 1. EspPhaseChanged(AccountSetup) -> Weak / account_setup_observed // 1. EspPhaseChanged(AccountSetup) -> Mode=Classic @ Medium / account_setup_observed
// 2. AadUserJoinedLate -> Weak / late_aadj_observed (stage unchanged) // 2. AadUserJoinedLate -> reason annotation only, stage unchanged
// 3. ImeUserSessionCompleted -> Strong / ime_user_session_completed // 3. ImeUserSessionCompleted -> Mode=Classic @ High / ime_user_session_completed
Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); Assert.Equal(EnrollmentMode.Classic, result.FinalState.ScenarioProfile.Mode);
Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence);
// Critical regression guard: the AadUserJoinedLate transition did NOT take the // Critical regression guard: the AadUserJoinedLate transition did NOT take the
// session to a terminal stage (stayed on EspAccountSetup). It is taken=true (the // session to a terminal stage (stayed on EspAccountSetup). It is taken=true (the
@@ -166,7 +170,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage); Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage);
// DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path. // DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path.
Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level); Assert.Equal(HypothesisLevel.Unknown, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
} }
} }
} }
@@ -39,8 +39,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
// Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved -> // Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved ->
// EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed. // EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed.
// Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG. // Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG.
Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Null(result.FinalState.ShellCoreWhiteGloveSuccessSeen); Assert.Null(result.FinalState.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen);
} }
[Fact] [Fact]
@@ -25,18 +25,19 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
// No user-presence facts should have been produced. // No user-presence facts should have been produced.
Assert.Null(result.FinalState.HelloResolvedUtc); Assert.Null(result.FinalState.HelloResolvedUtc);
Assert.Null(result.FinalState.DesktopArrivedUtc); Assert.Null(result.FinalState.DesktopArrivedUtc);
Assert.Null(result.FinalState.AadJoinedWithUser); Assert.Null(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved);
Assert.Null(result.FinalState.AccountSetupEnteredUtc); Assert.Null(result.FinalState.AccountSetupEnteredUtc);
// DeviceOnly hypothesis confirmed as DeviceOnly (no user presence seen). // DeviceOnly hypothesis confirmed as DeviceOnly (no user presence seen).
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
Assert.Equal( Assert.Equal(
DecisionEngine.DeviceOnlyReasons.DeviceOnly, DecisionEngine.DeviceOnlyReasons.DeviceOnly,
result.FinalState.DeviceOnlyDeployment.Reason); result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason);
// EnrollmentType upgraded to Strong by the SelfDeploying handler. // Profile upgraded to Mode=SelfDeploying @ High by the SelfDeploying handler.
Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); Assert.Equal(EnrollmentMode.SelfDeploying, result.FinalState.ScenarioProfile.Mode);
Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.EnrollmentType.Reason); Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence);
Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.ScenarioProfile.Reason);
Assert.Equal(3, result.Transitions.Count); Assert.Equal(3, result.Transitions.Count);
Assert.All(result.Transitions, t => Assert.True(t.Taken)); Assert.All(result.Transitions, t => Assert.True(t.Taken));
@@ -67,10 +68,10 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
// DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning). // DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning).
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
Assert.Equal( Assert.Equal(
DecisionEngine.DeviceOnlyReasons.DeviceOnly, DecisionEngine.DeviceOnlyReasons.DeviceOnly,
result.FinalState.DeviceOnlyDeployment.Reason); result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason);
// The deadline transition is present and taken (hypothesis update without stage change). // The deadline transition is present and taken (hypothesis update without stage change).
var deadlineTransition = Assert.Single( var deadlineTransition = Assert.Single(
@@ -98,7 +99,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.DoesNotContain( Assert.DoesNotContain(
result.FinalState.Deadlines, result.FinalState.Deadlines,
d => d.Name == DeadlineNames.DeviceOnlyEspDetection); d => d.Name == DeadlineNames.DeviceOnlyEspDetection);
Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level); Assert.Equal(HypothesisLevel.Unknown, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
} }
} }
} }
@@ -35,10 +35,10 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage); Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome); Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Equal(80, result.FinalState.WhiteGloveSealing.Score); Assert.Equal(80, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score);
Assert.NotNull(result.FinalState.ShellCoreWhiteGloveSuccessSeen); Assert.NotNull(result.FinalState.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen);
Assert.True(result.FinalState.ShellCoreWhiteGloveSuccessSeen!.Value); Assert.True(result.FinalState.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen!.Value);
Assert.Empty(result.FinalState.Deadlines); Assert.Empty(result.FinalState.Deadlines);
} }
@@ -51,8 +51,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage); Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome); Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.True(result.FinalState.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold); Assert.True(result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold);
// The tick fired between the two WG signals — snapshot unchanged at that point, // The tick fired between the two WG signals — snapshot unchanged at that point,
// so it was anti-loop-skipped. The SealingPattern run and the ShellCore run both // so it was anti-loop-skipped. The SealingPattern run and the ShellCore run both
@@ -74,12 +74,12 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
// WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser). // WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser).
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Rejected, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Equal(0, result.FinalState.WhiteGloveSealing.Score); Assert.Equal(0, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score);
// The late-AADJ fact + Hello + Desktop are all recorded. // The late-AADJ fact + Hello + Desktop are all recorded.
Assert.NotNull(result.FinalState.AadJoinedWithUser); Assert.NotNull(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved);
Assert.True(result.FinalState.AadJoinedWithUser!.Value); Assert.True(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved!.Value);
Assert.NotNull(result.FinalState.HelloResolvedUtc); Assert.NotNull(result.FinalState.HelloResolvedUtc);
Assert.NotNull(result.FinalState.DesktopArrivedUtc); Assert.NotNull(result.FinalState.DesktopArrivedUtc);
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
@@ -94,8 +94,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
// SealingPattern was seen but insufficient alone for Confirmed. // SealingPattern was seen but insufficient alone for Confirmed.
Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage); Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
Assert.Equal(HypothesisLevel.Weak, result.FinalState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Weak, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Equal(40, result.FinalState.WhiteGloveSealing.Score); Assert.Equal(40, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score);
var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0; var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0;
var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0; var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0;
@@ -29,8 +29,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage); Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage);
Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome); Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome);
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGlovePart2Completion.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
Assert.Equal(100, result.FinalState.WhiteGlovePart2Completion.Score); Assert.Equal(100, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Score);
// All four Part-2 facts should be present. // All four Part-2 facts should be present.
Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc); Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc);
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc); Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc);
// Part-1 hypothesis still Confirmed (not reset by the Part-2 transition). // Part-1 hypothesis still Confirmed (not reset by the Part-2 transition).
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
// SystemReboot fact recorded from SessionRecovered. // SystemReboot fact recorded from SessionRecovered.
Assert.NotNull(result.FinalState.SystemRebootUtc); Assert.NotNull(result.FinalState.SystemRebootUtc);
@@ -55,8 +55,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Equal(SessionStage.Failed, result.FinalState.Stage); Assert.Equal(SessionStage.Failed, result.FinalState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome); Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome);
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGlovePart2Completion.Level); Assert.Equal(HypothesisLevel.Rejected, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
Assert.Equal("part2_user_absent", result.FinalState.WhiteGlovePart2Completion.Reason); Assert.Equal("part2_user_absent", result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Reason);
// No user-signal facts captured. // No user-signal facts captured.
Assert.Null(result.FinalState.UserAadSignInCompleteUtc); Assert.Null(result.FinalState.UserAadSignInCompleteUtc);
@@ -65,7 +65,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc); Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc);
// Part 1 verdict remains Confirmed even as Part 2 failed. // Part 1 verdict remains Confirmed even as Part 2 failed.
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Empty(result.FinalState.Deadlines); Assert.Empty(result.FinalState.Deadlines);
} }
@@ -23,10 +23,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal("tenant-1", state.TenantId); Assert.Equal("tenant-1", state.TenantId);
Assert.Equal(SessionStage.SessionStarted, state.Stage); Assert.Equal(SessionStage.SessionStarted, state.Stage);
Assert.Null(state.Outcome); Assert.Null(state.Outcome);
Assert.Equal(HypothesisLevel.Unknown, state.EnrollmentType.Level); // Codex follow-up #5 — the legacy hypothesis fields are now aggregated into
Assert.Equal(HypothesisLevel.Unknown, state.WhiteGloveSealing.Level); // ScenarioProfile (derived classification) and ClassifierOutcomes (verdict storage).
Assert.Equal(HypothesisLevel.Unknown, state.WhiteGlovePart2Completion.Level); Assert.Equal(EnrollmentMode.Unknown, state.ScenarioProfile.Mode);
Assert.Equal(HypothesisLevel.Unknown, state.DeviceOnlyDeployment.Level); Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.DeviceOnlyDeployment.Level);
Assert.Empty(state.Deadlines); Assert.Empty(state.Deadlines);
Assert.Equal(-1, state.LastAppliedSignalOrdinal); Assert.Equal(-1, state.LastAppliedSignalOrdinal);
Assert.Equal(0, state.StepIndex); Assert.Equal(0, state.StepIndex);
@@ -0,0 +1,68 @@
using System;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Basic invariants for <see cref="ClassifierOutcomes"/>. Codex follow-up #5.
/// </summary>
public sealed class ClassifierOutcomesTests
{
[Fact]
public void Empty_allThreeHypothesesAreUnknown()
{
var o = ClassifierOutcomes.Empty;
Assert.Equal(HypothesisLevel.Unknown, o.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Unknown, o.WhiteGlovePart2Completion.Level);
Assert.Equal(HypothesisLevel.Unknown, o.DeviceOnlyDeployment.Level);
}
[Fact]
public void WithWhiteGloveSealing_setsFirstSlotOnly()
{
var h = Hypothesis.UnknownInstance.With(
level: HypothesisLevel.Confirmed,
reason: "classifier_confirmed",
score: 80,
lastUpdatedUtc: DateTime.UtcNow);
var outcomes = ClassifierOutcomes.Empty.WithWhiteGloveSealing(h);
Assert.Equal(HypothesisLevel.Confirmed, outcomes.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Unknown, outcomes.WhiteGlovePart2Completion.Level);
Assert.Equal(HypothesisLevel.Unknown, outcomes.DeviceOnlyDeployment.Level);
}
[Fact]
public void WithDeviceOnlyDeployment_preservesOtherSlots()
{
var wg = Hypothesis.UnknownInstance.With(level: HypothesisLevel.Weak, score: 40, lastUpdatedUtc: DateTime.UtcNow);
var outcomes = ClassifierOutcomes.Empty
.WithWhiteGloveSealing(wg)
.WithDeviceOnlyDeployment(Hypothesis.UnknownInstance.With(level: HypothesisLevel.Confirmed, score: 100, lastUpdatedUtc: DateTime.UtcNow));
Assert.Equal(HypothesisLevel.Weak, outcomes.WhiteGloveSealing.Level);
Assert.Equal(HypothesisLevel.Confirmed, outcomes.DeviceOnlyDeployment.Level);
}
[Fact]
public void Constructor_rejectsNullHypothesis()
{
Assert.Throws<ArgumentNullException>(() => new ClassifierOutcomes(
whiteGloveSealing: null!,
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
deviceOnlyDeployment: Hypothesis.UnknownInstance));
}
[Fact]
public void With_nullValue_throws()
{
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithWhiteGloveSealing(null!));
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithWhiteGlovePart2Completion(null!));
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithDeviceOnlyDeployment(null!));
}
}
}
@@ -0,0 +1,105 @@
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Set-once + ordinal semantics for <see cref="EnrollmentScenarioObservations"/>.
/// Codex follow-up #5.
/// </summary>
public sealed class EnrollmentScenarioObservationsTests
{
[Fact]
public void Empty_hasAllNullFacts()
{
var obs = EnrollmentScenarioObservations.Empty;
Assert.Null(obs.ShellCoreWhiteGloveSuccessSeen);
Assert.Null(obs.WhiteGloveSealingPatternSeen);
Assert.Null(obs.AadUserJoinWithUserObserved);
Assert.Null(obs.SkipUserEsp);
Assert.Null(obs.SkipDeviceEsp);
}
[Fact]
public void WithShellCoreWhiteGloveSuccessSeen_setsFact_preservesOrdinal()
{
var obs = EnrollmentScenarioObservations.Empty
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 17);
Assert.NotNull(obs.ShellCoreWhiteGloveSuccessSeen);
Assert.True(obs.ShellCoreWhiteGloveSuccessSeen!.Value);
Assert.Equal(17, obs.ShellCoreWhiteGloveSuccessSeen!.SourceSignalOrdinal);
}
[Fact]
public void WithShellCoreWhiteGloveSuccessSeen_alreadySet_isNoOp()
{
var seeded = EnrollmentScenarioObservations.Empty
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3);
var repeat = seeded.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 99);
// Set-once: first ordinal wins; second call is a no-op returning the same instance.
Assert.Same(seeded, repeat);
Assert.Equal(3, repeat.ShellCoreWhiteGloveSuccessSeen!.SourceSignalOrdinal);
}
[Fact]
public void WithAadUserJoinWithUserObserved_setsValueAndOrdinal()
{
var obs = EnrollmentScenarioObservations.Empty
.WithAadUserJoinWithUserObserved(value: true, sourceSignalOrdinal: 42);
Assert.NotNull(obs.AadUserJoinWithUserObserved);
Assert.True(obs.AadUserJoinWithUserObserved!.Value);
Assert.Equal(42, obs.AadUserJoinWithUserObserved!.SourceSignalOrdinal);
}
[Fact]
public void WithAadUserJoinWithUserObserved_recordsFalseValue()
{
var obs = EnrollmentScenarioObservations.Empty
.WithAadUserJoinWithUserObserved(value: false, sourceSignalOrdinal: 5);
Assert.False(obs.AadUserJoinWithUserObserved!.Value);
}
[Fact]
public void WithSkipUserEsp_thenWithSkipDeviceEsp_bothSet_independentOrdinals()
{
var obs = EnrollmentScenarioObservations.Empty
.WithSkipUserEsp(true, sourceSignalOrdinal: 1)
.WithSkipDeviceEsp(false, sourceSignalOrdinal: 2);
Assert.True(obs.SkipUserEsp!.Value);
Assert.Equal(1, obs.SkipUserEsp!.SourceSignalOrdinal);
Assert.False(obs.SkipDeviceEsp!.Value);
Assert.Equal(2, obs.SkipDeviceEsp!.SourceSignalOrdinal);
}
[Fact]
public void WithSkipUserEsp_alreadySet_isNoOpEvenIfValueDiffers()
{
var seeded = EnrollmentScenarioObservations.Empty
.WithSkipUserEsp(value: false, sourceSignalOrdinal: 1);
var repeat = seeded.WithSkipUserEsp(value: true, sourceSignalOrdinal: 99);
Assert.Same(seeded, repeat);
Assert.False(repeat.SkipUserEsp!.Value);
Assert.Equal(1, repeat.SkipUserEsp!.SourceSignalOrdinal);
}
[Fact]
public void Mutations_returnNewInstances()
{
var empty = EnrollmentScenarioObservations.Empty;
var mutated = empty.WithWhiteGloveSealingPatternSeen(7);
Assert.NotSame(empty, mutated);
Assert.Null(empty.WhiteGloveSealingPatternSeen);
Assert.NotNull(mutated.WhiteGloveSealingPatternSeen);
}
}
}
@@ -0,0 +1,75 @@
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Typ-Invarianten für <see cref="EnrollmentScenarioProfile"/> — Codex follow-up #5.
/// </summary>
public sealed class EnrollmentScenarioProfileTests
{
[Fact]
public void Empty_hasUnknownDefaults()
{
var p = EnrollmentScenarioProfile.Empty;
Assert.Equal(EnrollmentMode.Unknown, p.Mode);
Assert.Equal(EnrollmentJoinMode.Unknown, p.JoinMode);
Assert.Equal(EspConfig.Unknown, p.EspConfig);
Assert.Equal(PreProvisioningSide.None, p.PreProvisioningSide);
Assert.Equal(ProfileConfidence.Low, p.Confidence);
Assert.Equal(-1, p.EvidenceOrdinal);
Assert.Null(p.Reason);
}
[Fact]
public void With_omittedParam_preservesCurrentValue()
{
var seed = EnrollmentScenarioProfile.Empty.With(
mode: EnrollmentMode.Classic,
confidence: ProfileConfidence.Medium,
reason: "account_setup_observed",
evidenceOrdinal: 5);
var patched = seed.With(confidence: ProfileConfidence.High, evidenceOrdinal: 9);
Assert.Equal(EnrollmentMode.Classic, patched.Mode);
Assert.Equal(ProfileConfidence.High, patched.Confidence);
Assert.Equal("account_setup_observed", patched.Reason);
Assert.Equal(9, patched.EvidenceOrdinal);
Assert.Equal(EnrollmentJoinMode.Unknown, patched.JoinMode);
}
[Fact]
public void With_returnsNewInstance_originalUnchanged()
{
var seed = EnrollmentScenarioProfile.Empty;
var mutated = seed.With(mode: EnrollmentMode.WhiteGlove, confidence: ProfileConfidence.High);
Assert.Equal(EnrollmentMode.Unknown, seed.Mode);
Assert.Equal(EnrollmentMode.WhiteGlove, mutated.Mode);
Assert.NotSame(seed, mutated);
}
[Fact]
public void With_reasonNull_doesNotClear()
{
// Intentional: Reason is historical evidence; With(reason:null) is interpreted as
// "no change" (same semantic as Hypothesis.With).
var seed = EnrollmentScenarioProfile.Empty.With(reason: "initial");
var patched = seed.With(reason: null);
Assert.Equal("initial", patched.Reason);
}
[Theory]
[InlineData(ProfileConfidence.Low, ProfileConfidence.Medium, ProfileConfidence.Medium)]
[InlineData(ProfileConfidence.Low, ProfileConfidence.Low, ProfileConfidence.Low)]
[InlineData(ProfileConfidence.Medium, ProfileConfidence.Low, ProfileConfidence.Medium)]
[InlineData(ProfileConfidence.High, ProfileConfidence.Medium, ProfileConfidence.High)]
public void Confidence_monotonicHelper_picksHigher(ProfileConfidence a, ProfileConfidence b, ProfileConfidence expected)
{
Assert.Equal(expected, EnrollmentScenarioProfileUpdater.Max(a, b));
}
}
}
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Signal → <see cref="EnrollmentScenarioProfile"/> dispatch tests for
/// <see cref="EnrollmentScenarioProfileUpdater"/>. Codex follow-up #5.
/// Covers monotonic confidence, mode preservation, and the split-payload
/// EspConfig contract.
/// </summary>
public sealed class EnrollmentScenarioProfileUpdaterTests
{
private static readonly DateTime FixedUtc = new DateTime(2026, 4, 24, 12, 0, 0, DateTimeKind.Utc);
// ================================================================== SessionStarted
[Fact]
public void SessionStarted_v2_setsDevicePreparation_mediumConfidence()
{
var signal = MakeSignal(
DecisionSignalKind.SessionStarted,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v2",
[SignalPayloadKeys.IsHybridJoin] = "false",
});
var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.DevicePreparation, profile.Mode);
Assert.Equal(ProfileConfidence.Medium, profile.Confidence);
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
Assert.Equal("session_started:v2", profile.Reason);
Assert.Equal(0, profile.EvidenceOrdinal);
}
[Fact]
public void SessionStarted_v1_leavesModeUnknown_recordsReason()
{
var signal = MakeSignal(
DecisionSignalKind.SessionStarted,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
EnrollmentScenarioProfile.Empty, signal);
// v1 is ambiguous (Classic / SelfDeploying / WhiteGlove); later signals resolve it.
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
Assert.Equal(EnrollmentJoinMode.HybridAzureAdJoin, profile.JoinMode);
Assert.Equal("session_started:v1", profile.Reason);
}
[Fact]
public void SessionStarted_emptyPayload_returnsSameInstance()
{
var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
EnrollmentScenarioProfile.Empty, signal);
Assert.Same(EnrollmentScenarioProfile.Empty, profile);
}
// ================================================================== EspConfigDetected
[Fact]
public void EspConfigDetected_bothHalvesPresent_derivesEspConfig()
{
var signal = MakeSignal(
DecisionSignalKind.EspConfigDetected,
ordinal: 3,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "true",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
});
var (profile, obs) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, signal);
Assert.Equal(EspConfig.DeviceEspOnly, profile.EspConfig);
Assert.Equal(ProfileConfidence.Medium, profile.Confidence);
Assert.True(obs.SkipUserEsp!.Value);
Assert.False(obs.SkipDeviceEsp!.Value);
}
[Fact]
public void EspConfigDetected_partialFirstSignal_profileStaysUnknown_observationFilled()
{
var signal = MakeSignal(
DecisionSignalKind.EspConfigDetected,
ordinal: 1,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "true",
});
var (profile, obs) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, signal);
// Profile.EspConfig cannot be derived without both halves.
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
Assert.True(obs.SkipUserEsp!.Value);
Assert.Null(obs.SkipDeviceEsp);
}
[Fact]
public void EspConfigDetected_secondSignalCompletesPair_derivesEspConfig()
{
var first = MakeSignal(
DecisionSignalKind.EspConfigDetected,
ordinal: 1,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
});
var second = MakeSignal(
DecisionSignalKind.EspConfigDetected,
ordinal: 2,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipDeviceEsp] = "false",
});
var (p1, o1) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, first);
var (p2, o2) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(p1, o1, second);
Assert.Equal(EspConfig.Unknown, p1.EspConfig);
Assert.Equal(EspConfig.FullEsp, p2.EspConfig);
Assert.Equal(1, o2.SkipUserEsp!.SourceSignalOrdinal);
Assert.Equal(2, o2.SkipDeviceEsp!.SourceSignalOrdinal);
}
[Fact]
public void EspConfigDetected_setOnce_laterSignalDoesNotRegressEspConfig()
{
var seed = MakeSignal(
DecisionSignalKind.EspConfigDetected,
ordinal: 3,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
});
var flipped = MakeSignal(
DecisionSignalKind.EspConfigDetected,
ordinal: 9,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "true",
[SignalPayloadKeys.SkipDeviceEsp] = "true",
});
var (p1, o1) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, seed);
var (p2, _) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(p1, o1, flipped);
// Observations are set-once → Profile stays on the first derivation.
Assert.Equal(EspConfig.FullEsp, p2.EspConfig);
}
[Theory]
[InlineData(false, false, EspConfig.FullEsp)]
[InlineData(true, false, EspConfig.DeviceEspOnly)]
[InlineData(false, true, EspConfig.UserEspOnly)]
[InlineData(true, true, EspConfig.NoEsp)]
public void DeriveEspConfig_mapsAllFourCombinations(bool skipUser, bool skipDevice, EspConfig expected)
{
Assert.Equal(expected, EnrollmentScenarioProfileUpdater.DeriveEspConfig(skipUser, skipDevice));
}
// ================================================================== Mode promotions
[Fact]
public void AccountSetupObserved_modeUnknown_promotesToClassic_medium()
{
var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, ordinal: 5, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyAccountSetupObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.Classic, profile.Mode);
Assert.Equal(ProfileConfidence.Medium, profile.Confidence);
Assert.Equal("account_setup_observed", profile.Reason);
Assert.Equal(5, profile.EvidenceOrdinal);
}
[Fact]
public void AccountSetupObserved_modeAlreadySelfDeploying_isNoOp()
{
var seed = EnrollmentScenarioProfile.Empty.With(
mode: EnrollmentMode.SelfDeploying,
confidence: ProfileConfidence.High);
var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, ordinal: 5, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyAccountSetupObserved(seed, signal);
Assert.Same(seed, profile);
}
[Fact]
public void ImeUserSessionCompleted_upgradesToClassicHigh()
{
var seed = EnrollmentScenarioProfile.Empty.With(
mode: EnrollmentMode.Classic,
confidence: ProfileConfidence.Medium);
var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
Assert.Equal(EnrollmentMode.Classic, profile.Mode);
Assert.Equal(ProfileConfidence.High, profile.Confidence);
Assert.Equal("ime_user_session_completed", profile.Reason);
}
[Fact]
public void ImeUserSessionCompleted_alreadyHighDifferentMode_doesNotRegress()
{
var seed = EnrollmentScenarioProfile.Empty.With(
mode: EnrollmentMode.WhiteGlove,
confidence: ProfileConfidence.High);
var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
// Monotonic: Classic cannot overwrite a WhiteGlove classification at High confidence.
Assert.Same(seed, profile);
}
[Fact]
public void DeviceSetupProvisioningComplete_upgradesToSelfDeployingHigh()
{
var signal = MakeSignal(DecisionSignalKind.DeviceSetupProvisioningComplete, ordinal: 6, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
Assert.Equal(ProfileConfidence.High, profile.Confidence);
Assert.Equal("selfdeploying_provisioning_complete", profile.Reason);
}
[Fact]
public void WhiteGloveSealingConfirmed_setsWhiteGloveHigh_andTechnicianSide()
{
var signal = MakeSignal(DecisionSignalKind.ClassifierVerdictIssued, ordinal: 15, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.WhiteGlove, profile.Mode);
Assert.Equal(ProfileConfidence.High, profile.Confidence);
Assert.Equal(PreProvisioningSide.Technician, profile.PreProvisioningSide);
Assert.Equal("classifier_whiteglove_sealing_confirmed", profile.Reason);
}
// ================================================================== AadUserJoinedLate
[Fact]
public void AadUserJoinedLate_withUserTrue_recordsReason_butLeavesJoinModeUntouched()
{
var seed = EnrollmentScenarioProfile.Empty.With(joinMode: EnrollmentJoinMode.AzureAdJoin);
var signal = MakeSignal(DecisionSignalKind.AadUserJoinedLate, ordinal: 8, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyAadUserJoinedLate(seed, signal, withUser: true);
// Critical Codex correction: AadUserJoinedLate is NOT a JoinMode signal.
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
Assert.Equal("late_aadj_observed:withUser=true", profile.Reason);
Assert.Equal(8, profile.EvidenceOrdinal);
}
// ================================================================== helpers
private static DecisionSignal MakeSignal(
DecisionSignalKind kind,
long ordinal,
Dictionary<string, string>? payload)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: FixedUtc,
sourceOrigin: "test",
evidence: new Evidence(kind: EvidenceKind.Synthetic, identifier: "test", summary: "test"),
payload: payload);
}
}
}
@@ -27,8 +27,8 @@ namespace AutopilotMonitor.DecisionCore.Classifiers
return classifierId switch return classifierId switch
{ {
WhiteGloveSealingClassifier.ClassifierId => state.WhiteGloveSealing.LastClassifierVerdictId, WhiteGloveSealingClassifier.ClassifierId => state.ClassifierOutcomes.WhiteGloveSealing.LastClassifierVerdictId,
WhiteGlovePart2CompletionClassifier.ClassifierId => state.WhiteGlovePart2Completion.LastClassifierVerdictId, WhiteGlovePart2CompletionClassifier.ClassifierId => state.ClassifierOutcomes.WhiteGlovePart2Completion.LastClassifierVerdictId,
_ => null, _ => null,
}; };
} }
@@ -69,6 +69,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>On <c>EspConfigDetected</c>: "true" / "false"; missing → fact is not set.</summary> /// <summary>On <c>EspConfigDetected</c>: "true" / "false"; missing → fact is not set.</summary>
public const string SkipDeviceEsp = "skipDeviceEsp"; public const string SkipDeviceEsp = "skipDeviceEsp";
/// <summary>On <c>SessionStarted</c>: literal "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType()</c>.</summary>
public const string EnrollmentType = "enrollmentType";
/// <summary>On <c>SessionStarted</c>: "true" / "false" from <c>EnrollmentRegistryDetector.DetectHybridJoin()</c>.</summary>
public const string IsHybridJoin = "isHybridJoin";
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------ // --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the // Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are // EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
@@ -72,15 +72,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.FinalizingEnteredUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal); builder.FinalizingEnteredUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
} }
// Weak UserDriven-v1 hypothesis — seeing AccountSetup is the canonical tell. // Classic UserDriven-v1 tell — seeing AccountSetup promotes the scenario profile.
if (enrollmentPhase == EnrollmentPhase.AccountSetup && // Codex follow-up #5: Mode promotion is delegated to EnrollmentScenarioProfileUpdater
state.EnrollmentType.Level == HypothesisLevel.Unknown) // so the monotonic-confidence rule stays in one place.
if (enrollmentPhase == EnrollmentPhase.AccountSetup)
{ {
builder.EnrollmentType = state.EnrollmentType.With( builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyAccountSetupObserved(
level: HypothesisLevel.Weak, builder.ScenarioProfile, signal);
reason: "account_setup_observed",
score: 40,
lastUpdatedUtc: signal.OccurredAtUtc);
} }
// Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup. // Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup.
@@ -297,15 +295,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal); builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal);
} }
// Strengthen UserDriven-v1 hypothesis on user-session-complete. // Classic High-confidence promotion on user-session-complete. Codex follow-up #5:
if (state.EnrollmentType.Level < HypothesisLevel.Strong) // delegate to the updater for monotonicity.
{ builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(
builder.EnrollmentType = state.EnrollmentType.With( builder.ScenarioProfile, signal);
level: HypothesisLevel.Strong,
reason: "ime_user_session_completed",
score: 75,
lastUpdatedUtc: signal.OccurredAtUtc);
}
var newState = builder.Build(); var newState = builder.Build();
@@ -320,14 +313,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
} }
/// <summary> /// <summary>
/// Handle <see cref="DecisionSignalKind.AadUserJoinedLate"/> — hypothesis-only update. /// Handle <see cref="DecisionSignalKind.AadUserJoinedLate"/> — observation-only update.
/// <para> /// <para>
/// Per project memory <c>feedback_aad_joined_late_not_completion</c>: this signal is a /// Per project memory <c>feedback_aad_joined_late_not_completion</c>: this signal is a
/// classifier-state update ONLY — never a completion trigger. Stage is unchanged, no /// classifier-state update ONLY — never a completion trigger. Stage is unchanged, no
/// terminal event is emitted, but <see cref="DecisionState.AadJoinedWithUser"/> is set /// terminal event is emitted. Codex follow-up #5: the user-presence flag lives in
/// and the <see cref="DecisionState.EnrollmentType"/> hypothesis records the late-AADJ /// <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/> (NOT
/// reason so downstream classifiers (WhiteGlovePart2CompletionClassifier in M3.4) can /// <see cref="EnrollmentScenarioProfile.JoinMode"/> — that is strictly the
/// factor it in. /// <c>SessionStarted.payload.isHybridJoin</c> value), and the profile's
/// <see cref="EnrollmentScenarioProfile.Reason"/> is annotated without touching Mode.
/// </para> /// </para>
/// </summary> /// </summary>
private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal) private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal)
@@ -340,12 +334,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
var builder = state.ToBuilder() var builder = state.ToBuilder()
.WithStepIndex(nextStep) .WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.AadJoinedWithUser = new SignalFact<bool>(withUser, signal.SessionSignalOrdinal);
// Annotate the EnrollmentType hypothesis reason without bumping level up. builder.ScenarioObservations = builder.ScenarioObservations.WithAadUserJoinWithUserObserved(
builder.EnrollmentType = state.EnrollmentType.With( value: withUser, sourceSignalOrdinal: signal.SessionSignalOrdinal);
reason: $"late_aadj_observed:withUser={withUser.ToString().ToLowerInvariant()}", builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyAadUserJoinedLate(
lastUpdatedUtc: signal.OccurredAtUtc); builder.ScenarioProfile, signal, withUser);
var newState = builder.Build(); var newState = builder.Build();
@@ -64,8 +64,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
} }
// SelfDeploying / Device-Only terminal path. // 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.
var hasUserPresence = var hasUserPresence =
(state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) || (state.ScenarioObservations.AadUserJoinWithUserObserved != null
&& state.ScenarioObservations.AadUserJoinWithUserObserved.Value) ||
state.HelloResolvedUtc != null || state.HelloResolvedUtc != null ||
state.DesktopArrivedUtc != null; state.DesktopArrivedUtc != null;
@@ -73,7 +77,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
? DeviceOnlyReasons.UserPresent ? DeviceOnlyReasons.UserPresent
: DeviceOnlyReasons.DeviceOnly; : DeviceOnlyReasons.DeviceOnly;
var updatedHypothesis = state.DeviceOnlyDeployment.With( var updatedDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With(
level: HypothesisLevel.Confirmed, level: HypothesisLevel.Confirmed,
reason: deviceOnlyReason, reason: deviceOnlyReason,
score: 100, score: 100,
@@ -85,18 +89,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithStepIndex(nextStep) .WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.ClearDeadlines(); .ClearDeadlines();
builder.DeviceOnlyDeployment = updatedHypothesis; builder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(updatedDeviceOnly);
// Strengthen EnrollmentType: observing DeviceSetupProvisioningComplete without // Strengthen Mode: observing DeviceSetupProvisioningComplete without preceding
// preceding AccountSetup strongly implies SelfDeploying-v1. // AccountSetup strongly implies SelfDeploying-v1. Delegated to the updater.
if (state.EnrollmentType.Level < HypothesisLevel.Strong) builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete(
{ builder.ScenarioProfile, signal);
builder.EnrollmentType = state.EnrollmentType.With(
level: HypothesisLevel.Strong,
reason: "selfdeploying_provisioning_complete",
score: 80,
lastUpdatedUtc: signal.OccurredAtUtc);
}
var newState = builder.Build(); var newState = builder.Build();
@@ -127,11 +125,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection);
builder.DeviceOnlyDeployment = state.DeviceOnlyDeployment.With( var strongDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With(
level: HypothesisLevel.Strong, level: HypothesisLevel.Strong,
reason: DeviceOnlyReasons.DeviceOnly, reason: DeviceOnlyReasons.DeviceOnly,
score: 70, score: 70,
lastUpdatedUtc: signal.OccurredAtUtc); lastUpdatedUtc: signal.OccurredAtUtc);
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(strongDeviceOnly);
var newState = builder.Build(); var newState = builder.Build();
@@ -53,13 +53,19 @@ namespace AutopilotMonitor.DecisionCore.Engine
// periodically from the very start of the session — Plan §4.x M4.4.4. // periodically from the very start of the session — Plan §4.x M4.4.4.
var classifierTick = BuildClassifierTickDeadline(signal.OccurredAtUtc); var classifierTick = BuildClassifierTickDeadline(signal.OccurredAtUtc);
// Stage stays SessionStarted; this transition is the "we saw the start" anchor. // Codex follow-up #5 — seed the scenario profile from the registry-detector payload
var newState = state.ToBuilder() // (enrollmentType + isHybridJoin). Stage stays SessionStarted; this transition is the
// "we saw the start" anchor.
var builder = state.ToBuilder()
.WithStage(SessionStage.SessionStarted) .WithStage(SessionStage.SessionStarted)
.WithStepIndex(state.StepIndex + 1) .WithStepIndex(state.StepIndex + 1)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.AddDeadline(classifierTick) .AddDeadline(classifierTick);
.Build();
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
builder.ScenarioProfile, signal);
var newState = builder.Build();
var transition = BuildTakenTransition( var transition = BuildTakenTransition(
before: state, before: state,
@@ -458,15 +464,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead)); RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead));
/// <summary> /// <summary>
/// Handle <see cref="DecisionSignalKind.EspConfigDetected"/>. Plan §6 Fix 9. /// Handle <see cref="DecisionSignalKind.EspConfigDetected"/>. Plan §6 Fix 9 +
/// Codex follow-up #5.
/// <para> /// <para>
/// Populates <see cref="DecisionState.SkipUserEsp"/> / <see cref="DecisionState.SkipDeviceEsp"/> /// Populates <see cref="EnrollmentScenarioObservations.SkipUserEsp"/> /
/// from the payload (<see cref="SignalPayloadKeys.SkipUserEsp"/> / /// <see cref="EnrollmentScenarioObservations.SkipDeviceEsp"/> observations and, when
/// <see cref="SignalPayloadKeys.SkipDeviceEsp"/>). Set-once semantics — later signals with /// both halves are known, derives <see cref="EnrollmentScenarioProfile.EspConfig"/>.
/// identical payload are no-ops, and keys missing from the payload never clear a fact that /// Set-once semantics — later signals with identical payload are no-ops, and keys
/// was already set (a re-read that failed to pick up one half must not invalidate the /// missing from the payload never clear a fact that was already set (a re-read that
/// other). Stage is unchanged; this is a fact-only signal that Fix 8's reducer guards /// failed to pick up one half must not invalidate the other). Stage is unchanged;
/// read. /// this is a fact-only signal that Fix 8's reducer guards read via
/// <see cref="EnrollmentScenarioProfile.EspConfig"/>.
/// </para> /// </para>
/// </summary> /// </summary>
private DecisionStep HandleEspConfigDetectedV1(DecisionState state, DecisionSignal signal) private DecisionStep HandleEspConfigDetectedV1(DecisionState state, DecisionSignal signal)
@@ -476,22 +484,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithStepIndex(nextStep) .WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
if (signal.Payload != null) var (newProfile, newObservations) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
{ builder.ScenarioProfile, builder.ScenarioObservations, signal);
if (state.SkipUserEsp == null builder.ScenarioProfile = newProfile;
&& signal.Payload.TryGetValue(SignalPayloadKeys.SkipUserEsp, out var rawSkipUser) builder.ScenarioObservations = newObservations;
&& bool.TryParse(rawSkipUser, out var skipUser))
{
builder.SkipUserEsp = new SignalFact<bool>(skipUser, signal.SessionSignalOrdinal);
}
if (state.SkipDeviceEsp == null
&& signal.Payload.TryGetValue(SignalPayloadKeys.SkipDeviceEsp, out var rawSkipDevice)
&& bool.TryParse(rawSkipDevice, out var skipDevice))
{
builder.SkipDeviceEsp = new SignalFact<bool>(skipDevice, signal.SessionSignalOrdinal);
}
}
var newState = builder.Build(); var newState = builder.Build();
var transition = BuildTakenTransition( var transition = BuildTakenTransition(
@@ -595,19 +591,29 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// on Classic V1 paths. Returns <c>true</c> only when the promotion is legitimate: /// on Classic V1 paths. Returns <c>true</c> only when the promotion is legitimate:
/// <list type="bullet"> /// <list type="bullet">
/// <item>AccountSetup has already been entered (the post-Account-ESP final exit case), or</item> /// <item>AccountSetup has already been entered (the post-Account-ESP final exit case), or</item>
/// <item>SkipUser is explicitly <c>true</c> (the Account-ESP phase is skipped; first /// <item>The profile's <see cref="EnrollmentScenarioProfile.EspConfig"/> resolves to
/// esp_exiting IS the final exit on device-only / full-skip flows).</item> /// a shape where Account-ESP never runs (<see cref="EspConfig.DeviceEspOnly"/>
/// = skipUser=true, or <see cref="EspConfig.NoEsp"/> = skipUser=true AND
/// skipDevice=true). In those shapes the first esp_exiting IS the final exit.</item>
/// </list> /// </list>
/// Otherwise returns <c>false</c> — a FinalizingSetup / EspExiting signal arriving /// Otherwise returns <c>false</c> — a FinalizingSetup / EspExiting signal arriving
/// before AccountSetup on a non-SkipUser enrollment is either a collector bug or the /// before AccountSetup on a non-SkipUser enrollment is either a collector bug or the
/// Device-ESP intermediate exit that Fix 7 otherwise swallows at the tracker layer. /// Device-ESP intermediate exit that Fix 7 otherwise swallows at the tracker layer.
/// Keeping this helper in the reducer means even a regression in Fix 7 cannot drive a /// Keeping this helper in the reducer means even a regression in Fix 7 cannot drive a
/// premature <c>AwaitingHello</c> + HelloSafety arm. /// premature <c>AwaitingHello</c> + HelloSafety arm.
/// <para>
/// Codex follow-up #5: the legacy <c>SkipUserEsp?.Value == true</c> check now reads
/// <see cref="DecisionState.ScenarioProfile"/> (derived enum) instead of the
/// half-fact. Semantically equivalent: DeviceEspOnly ≡ skipUser=true && skipDevice=false,
/// NoEsp ≡ skipUser=true && skipDevice=true. An <see cref="EspConfig.Unknown"/> profile
/// (EspConfigDetected not yet seen, or only one half parsed) blocks the promotion.
/// </para>
/// </summary> /// </summary>
private static bool ShouldTransitionToAwaitingHello(DecisionState state) private static bool ShouldTransitionToAwaitingHello(DecisionState state)
{ {
if (state.AccountSetupEnteredUtc != null) return true; if (state.AccountSetupEnteredUtc != null) return true;
if (state.SkipUserEsp?.Value == true) return true; var esp = state.ScenarioProfile.EspConfig;
if (esp == EspConfig.DeviceEspOnly || esp == EspConfig.NoEsp) return true;
return false; return false;
} }
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
var builder = state.ToBuilder() var builder = state.ToBuilder()
.WithStepIndex(nextStep) .WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.ShellCoreWhiteGloveSuccessSeen = new SignalFact<bool>(true, signal.SessionSignalOrdinal); builder.ScenarioObservations = builder.ScenarioObservations.WithShellCoreWhiteGloveSuccessSeen(signal.SessionSignalOrdinal);
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal); var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
var builder = state.ToBuilder() var builder = state.ToBuilder()
.WithStepIndex(nextStep) .WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.WhiteGloveSealingPatternSeen = new SignalFact<bool>(true, signal.SessionSignalOrdinal); builder.ScenarioObservations = builder.ScenarioObservations.WithWhiteGloveSealingPatternSeen(signal.SessionSignalOrdinal);
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal); var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
@@ -105,15 +105,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
if (classifier == WhiteGloveSealingClassifier.ClassifierId) if (classifier == WhiteGloveSealingClassifier.ClassifierId)
{ {
builder.WhiteGloveSealing = state.WhiteGloveSealing.With( var updatedSealing = state.ClassifierOutcomes.WhiteGloveSealing.With(
level: level, level: level,
reason: reason, reason: reason,
score: score, score: score,
lastUpdatedUtc: signal.OccurredAtUtc, lastUpdatedUtc: signal.OccurredAtUtc,
lastClassifierVerdictId: inputHash); lastClassifierVerdictId: inputHash);
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGloveSealing(updatedSealing);
if (level == HypothesisLevel.Confirmed) if (level == HypothesisLevel.Confirmed)
{ {
// Mirror the WG decision in the profile: Mode=WhiteGlove @ High confidence.
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(
builder.ScenarioProfile, signal);
builder builder
.WithStage(SessionStage.WhiteGloveSealed) .WithStage(SessionStage.WhiteGloveSealed)
.WithOutcome(SessionOutcome.WhiteGlovePart1Sealed) .WithOutcome(SessionOutcome.WhiteGlovePart1Sealed)
@@ -242,18 +247,26 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// Build a <see cref="WhiteGloveSealingSnapshot"/> from the current <see cref="DecisionState"/>. /// Build a <see cref="WhiteGloveSealingSnapshot"/> from the current <see cref="DecisionState"/>.
/// Exposed to the effect runner so the verdict snapshot carried with the /// Exposed to the effect runner so the verdict snapshot carried with the
/// <see cref="DecisionEffectKind.RunClassifier"/> effect is deterministic from state. /// <see cref="DecisionEffectKind.RunClassifier"/> effect is deterministic from state.
/// <para>
/// Codex follow-up #5 — the snapshot fields are unchanged, only their sources moved:
/// the three Boolean signal-observation inputs come from
/// <see cref="DecisionState.ScenarioObservations"/>, the device-only input from
/// <see cref="DecisionState.ClassifierOutcomes"/>. The input-hash canonicalization in
/// <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> is therefore byte-stable
/// across the refactor, which preserves the anti-loop semantics.
/// </para>
/// </summary> /// </summary>
internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) => internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) =>
new WhiteGloveSealingSnapshot( new WhiteGloveSealingSnapshot(
shellCoreWhiteGloveSuccessSeen: state.ShellCoreWhiteGloveSuccessSeen?.Value == true, shellCoreWhiteGloveSuccessSeen: state.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen?.Value == true,
whiteGloveSealingPatternSeen: state.WhiteGloveSealingPatternSeen?.Value == true, whiteGloveSealingPatternSeen: state.ScenarioObservations.WhiteGloveSealingPatternSeen?.Value == true,
aadJoinedWithUser: state.AadJoinedWithUser?.Value == true, aadJoinedWithUser: state.ScenarioObservations.AadUserJoinWithUserObserved?.Value == true,
desktopArrived: state.DesktopArrivedUtc != null, desktopArrived: state.DesktopArrivedUtc != null,
helloResolved: state.HelloResolvedUtc != null, helloResolved: state.HelloResolvedUtc != null,
hasAccountSetupActivity: state.AccountSetupEnteredUtc != null, hasAccountSetupActivity: state.AccountSetupEnteredUtc != null,
isDeviceOnlyDeploymentHypothesis: isDeviceOnlyDeploymentHypothesis:
state.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong && state.ClassifierOutcomes.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong &&
state.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly, state.ClassifierOutcomes.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly,
systemRebootUtc: state.SystemRebootUtc?.Value, systemRebootUtc: state.SystemRebootUtc?.Value,
currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value); currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value);
@@ -116,11 +116,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.ClearDeadlines(); .ClearDeadlines();
builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With( var rejectedPart2 = state.ClassifierOutcomes.WhiteGlovePart2Completion.With(
level: HypothesisLevel.Rejected, level: HypothesisLevel.Rejected,
reason: "part2_user_absent", reason: "part2_user_absent",
score: 0, score: 0,
lastUpdatedUtc: signal.OccurredAtUtc); lastUpdatedUtc: signal.OccurredAtUtc);
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGlovePart2Completion(rejectedPart2);
var newState = builder.Build(); var newState = builder.Build();
@@ -219,12 +220,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithStepIndex(nextStep) .WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With( var updatedPart2 = state.ClassifierOutcomes.WhiteGlovePart2Completion.With(
level: level, level: level,
reason: reason, reason: reason,
score: score, score: score,
lastUpdatedUtc: signal.OccurredAtUtc, lastUpdatedUtc: signal.OccurredAtUtc,
lastClassifierVerdictId: inputHash); lastClassifierVerdictId: inputHash);
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGlovePart2Completion(updatedPart2);
if (level == HypothesisLevel.Confirmed) if (level == HypothesisLevel.Confirmed)
{ {
@@ -0,0 +1,68 @@
using System;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Container for the reducer-side classifier verdict + anti-loop state. Codex follow-up #5 —
/// replaces the legacy <c>WhiteGloveSealing</c> / <c>WhiteGlovePart2Completion</c> /
/// <c>DeviceOnlyDeployment</c> <see cref="Hypothesis"/> fields on <c>DecisionState</c> with a
/// single aggregate. Scoring logic, weights and thresholds live in the classifier
/// implementations (<c>WhiteGloveSealingClassifier</c>, <c>WhiteGlovePart2CompletionClassifier</c>)
/// and are not touched by this refactor — only the verdict storage moved.
/// <para>
/// <b>Invariants</b>:
/// <list type="bullet">
/// <item>All three <see cref="Hypothesis"/> values are non-null; <see cref="Empty"/> uses <see cref="Hypothesis.UnknownInstance"/>.</item>
/// <item>Immutable; the <c>With…</c> methods return new instances.</item>
/// <item><see cref="DeviceOnlyDeployment"/> has no dedicated classifier — it's updated by the reducer (<c>DeviceSetupProvisioningComplete</c> handler + <c>DeviceOnlyEspDetection</c> deadline) but is modeled here for symmetry, because the WhiteGlove classifier reads it as an input.</item>
/// </list>
/// </para>
/// </summary>
public sealed class ClassifierOutcomes
{
public static readonly ClassifierOutcomes Empty = new ClassifierOutcomes(
whiteGloveSealing: Hypothesis.UnknownInstance,
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
deviceOnlyDeployment: Hypothesis.UnknownInstance);
public ClassifierOutcomes(
Hypothesis whiteGloveSealing,
Hypothesis whiteGlovePart2Completion,
Hypothesis deviceOnlyDeployment)
{
WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing));
WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion));
DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(deviceOnlyDeployment));
}
/// <summary>Verdict from <c>WhiteGloveSealingClassifier</c>. Plan §2.4.</summary>
public Hypothesis WhiteGloveSealing { get; }
/// <summary>Verdict from <c>WhiteGlovePart2CompletionClassifier</c>. Plan §2.4 / §M3.4.</summary>
public Hypothesis WhiteGlovePart2Completion { get; }
/// <summary>
/// Reducer-maintained (no dedicated classifier) device-only hypothesis. Inputs into the
/// WhiteGlove sealing snapshot as <c>IsDeviceOnlyDeploymentHypothesis</c>.
/// </summary>
public Hypothesis DeviceOnlyDeployment { get; }
public ClassifierOutcomes WithWhiteGloveSealing(Hypothesis value) =>
new ClassifierOutcomes(
value ?? throw new ArgumentNullException(nameof(value)),
WhiteGlovePart2Completion,
DeviceOnlyDeployment);
public ClassifierOutcomes WithWhiteGlovePart2Completion(Hypothesis value) =>
new ClassifierOutcomes(
WhiteGloveSealing,
value ?? throw new ArgumentNullException(nameof(value)),
DeviceOnlyDeployment);
public ClassifierOutcomes WithDeviceOnlyDeployment(Hypothesis value) =>
new ClassifierOutcomes(
WhiteGloveSealing,
WhiteGlovePart2Completion,
value ?? throw new ArgumentNullException(nameof(value)));
}
}
@@ -12,6 +12,18 @@ namespace AutopilotMonitor.DecisionCore.State
/// Reducer contract: <c>(newState, transition, effects) = engine.Reduce(oldState, signal)</c>. /// Reducer contract: <c>(newState, transition, effects) = engine.Reduce(oldState, signal)</c>.
/// </para> /// </para>
/// <para> /// <para>
/// Codex follow-up #5 — the legacy 9-field hypothesis/fact mosaic
/// (<c>EnrollmentType</c>, <c>WhiteGloveSealing</c>, <c>WhiteGlovePart2Completion</c>,
/// <c>DeviceOnlyDeployment</c>, <c>SkipUserEsp</c>, <c>SkipDeviceEsp</c>,
/// <c>AadJoinedWithUser</c>, <c>ShellCoreWhiteGloveSuccessSeen</c>,
/// <c>WhiteGloveSealingPatternSeen</c>) has been replaced by three structured aggregates:
/// <list type="bullet">
/// <item><see cref="ScenarioProfile"/> — the derived semantic classification.</item>
/// <item><see cref="ScenarioObservations"/> — raw per-signal observations feeding the classifier.</item>
/// <item><see cref="ClassifierOutcomes"/> — classifier verdicts + anti-loop state.</item>
/// </list>
/// </para>
/// <para>
/// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live /// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live
/// in <c>agent-lifecycle.json</c>, not here (L.11 separation). /// in <c>agent-lifecycle.json</c>, not here (L.11 separation).
/// </para> /// </para>
@@ -25,10 +37,6 @@ namespace AutopilotMonitor.DecisionCore.State
string tenantId, string tenantId,
SessionStage stage, SessionStage stage,
SessionOutcome? outcome, SessionOutcome? outcome,
Hypothesis enrollmentType,
Hypothesis whiteGloveSealing,
Hypothesis whiteGlovePart2Completion,
Hypothesis deviceOnlyDeployment,
SignalFact<EnrollmentPhase>? currentEnrollmentPhase, SignalFact<EnrollmentPhase>? currentEnrollmentPhase,
SignalFact<DateTime>? deviceSetupEnteredUtc, SignalFact<DateTime>? deviceSetupEnteredUtc,
SignalFact<DateTime>? accountSetupEnteredUtc, SignalFact<DateTime>? accountSetupEnteredUtc,
@@ -38,20 +46,18 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<DateTime>? helloResolvedUtc, SignalFact<DateTime>? helloResolvedUtc,
SignalFact<DateTime>? systemRebootUtc, SignalFact<DateTime>? systemRebootUtc,
SignalFact<string>? helloOutcome, SignalFact<string>? helloOutcome,
SignalFact<bool>? aadJoinedWithUser,
SignalFact<string>? imeMatchedPatternId, SignalFact<string>? imeMatchedPatternId,
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
SignalFact<bool>? whiteGloveSealingPatternSeen,
SignalFact<DateTime>? userAadSignInCompleteUtc, SignalFact<DateTime>? userAadSignInCompleteUtc,
SignalFact<DateTime>? helloResolvedPart2Utc, SignalFact<DateTime>? helloResolvedPart2Utc,
SignalFact<DateTime>? desktopArrivedPart2Utc, SignalFact<DateTime>? desktopArrivedPart2Utc,
SignalFact<DateTime>? accountSetupCompletedPart2Utc, SignalFact<DateTime>? accountSetupCompletedPart2Utc,
SignalFact<bool>? skipUserEsp,
SignalFact<bool>? skipDeviceEsp,
IReadOnlyList<ActiveDeadline> deadlines, IReadOnlyList<ActiveDeadline> deadlines,
long lastAppliedSignalOrdinal, long lastAppliedSignalOrdinal,
int stepIndex, int stepIndex,
AppInstallFacts? appInstallFacts = null, AppInstallFacts? appInstallFacts = null,
EnrollmentScenarioProfile? scenarioProfile = null,
EnrollmentScenarioObservations? scenarioObservations = null,
ClassifierOutcomes? classifierOutcomes = null,
string? schemaVersion = null) string? schemaVersion = null)
{ {
if (string.IsNullOrEmpty(sessionId)) if (string.IsNullOrEmpty(sessionId))
@@ -68,10 +74,6 @@ namespace AutopilotMonitor.DecisionCore.State
TenantId = tenantId; TenantId = tenantId;
Stage = stage; Stage = stage;
Outcome = outcome; Outcome = outcome;
EnrollmentType = enrollmentType ?? throw new ArgumentNullException(nameof(enrollmentType));
WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing));
WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion));
DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(deviceOnlyDeployment));
CurrentEnrollmentPhase = currentEnrollmentPhase; CurrentEnrollmentPhase = currentEnrollmentPhase;
DeviceSetupEnteredUtc = deviceSetupEnteredUtc; DeviceSetupEnteredUtc = deviceSetupEnteredUtc;
AccountSetupEnteredUtc = accountSetupEnteredUtc; AccountSetupEnteredUtc = accountSetupEnteredUtc;
@@ -81,20 +83,18 @@ namespace AutopilotMonitor.DecisionCore.State
HelloResolvedUtc = helloResolvedUtc; HelloResolvedUtc = helloResolvedUtc;
SystemRebootUtc = systemRebootUtc; SystemRebootUtc = systemRebootUtc;
HelloOutcome = helloOutcome; HelloOutcome = helloOutcome;
AadJoinedWithUser = aadJoinedWithUser;
ImeMatchedPatternId = imeMatchedPatternId; ImeMatchedPatternId = imeMatchedPatternId;
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
UserAadSignInCompleteUtc = userAadSignInCompleteUtc; UserAadSignInCompleteUtc = userAadSignInCompleteUtc;
HelloResolvedPart2Utc = helloResolvedPart2Utc; HelloResolvedPart2Utc = helloResolvedPart2Utc;
DesktopArrivedPart2Utc = desktopArrivedPart2Utc; DesktopArrivedPart2Utc = desktopArrivedPart2Utc;
AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc; AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc;
SkipUserEsp = skipUserEsp;
SkipDeviceEsp = skipDeviceEsp;
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines)); Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal; LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
StepIndex = stepIndex; StepIndex = stepIndex;
AppInstallFacts = appInstallFacts ?? AppInstallFacts.Empty; AppInstallFacts = appInstallFacts ?? AppInstallFacts.Empty;
ScenarioProfile = scenarioProfile ?? EnrollmentScenarioProfile.Empty;
ScenarioObservations = scenarioObservations ?? EnrollmentScenarioObservations.Empty;
ClassifierOutcomes = classifierOutcomes ?? ClassifierOutcomes.Empty;
SchemaVersion = schemaVersion ?? CurrentSchemaVersion; SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
} }
@@ -108,15 +108,6 @@ namespace AutopilotMonitor.DecisionCore.State
/// <summary>Null when the session is non-terminal.</summary> /// <summary>Null when the session is non-terminal.</summary>
public SessionOutcome? Outcome { get; } public SessionOutcome? Outcome { get; }
// --- Hypothesen ---
public Hypothesis EnrollmentType { get; }
public Hypothesis WhiteGloveSealing { get; }
public Hypothesis WhiteGlovePart2Completion { get; }
public Hypothesis DeviceOnlyDeployment { get; }
// --- Enrollment-Phase (end-user reality, separate from Stage) --- // --- Enrollment-Phase (end-user reality, separate from Stage) ---
public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; } public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; }
@@ -137,29 +128,14 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<string>? HelloOutcome { get; } public SignalFact<string>? HelloOutcome { get; }
public SignalFact<bool>? AadJoinedWithUser { get; }
public SignalFact<string>? ImeMatchedPatternId { get; } public SignalFact<string>? ImeMatchedPatternId { get; }
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> has fired. WG Part-1 indicator.</summary>
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; }
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired. Signal-correlated WG path.</summary>
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
// --- WhiteGlove Part 2 (post-reboot user sign-in) facts --- // --- WhiteGlove Part 2 (post-reboot user sign-in) facts ---
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; } public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; }
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; } public SignalFact<DateTime>? HelloResolvedPart2Utc { get; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; } public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; } public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; }
// --- ESP skip-flag facts from EspConfigDetected (plan §6 Fix 9). SkipUser=true means the
// Account-ESP phase never runs on this enrollment, so AwaitingHello is reachable directly
// after Device-ESP. When null the configuration has not been observed yet — guards that
// rely on an explicit bool must treat null as "unknown" rather than "false".
public SignalFact<bool>? SkipUserEsp { get; }
public SignalFact<bool>? SkipDeviceEsp { get; }
public IReadOnlyList<ActiveDeadline> Deadlines { get; } public IReadOnlyList<ActiveDeadline> Deadlines { get; }
public long LastAppliedSignalOrdinal { get; } public long LastAppliedSignalOrdinal { get; }
@@ -173,6 +149,27 @@ namespace AutopilotMonitor.DecisionCore.State
/// </summary> /// </summary>
public AppInstallFacts AppInstallFacts { get; } public AppInstallFacts AppInstallFacts { get; }
/// <summary>
/// Typed enrollment-scenario classification (Mode / JoinMode / EspConfig /
/// PreProvisioningSide / Confidence). Codex follow-up #5. Never null; defaults to
/// <see cref="EnrollmentScenarioProfile.Empty"/>.
/// </summary>
public EnrollmentScenarioProfile ScenarioProfile { get; }
/// <summary>
/// Raw per-signal observations feeding the WhiteGlove sealing classifier and downstream
/// guards. Codex follow-up #5. Never null; defaults to
/// <see cref="EnrollmentScenarioObservations.Empty"/>.
/// </summary>
public EnrollmentScenarioObservations ScenarioObservations { get; }
/// <summary>
/// Classifier verdict storage + anti-loop state (WhiteGlove sealing, WhiteGlove Part 2,
/// device-only deployment). Codex follow-up #5. Never null; defaults to
/// <see cref="ClassifierOutcomes.Empty"/>.
/// </summary>
public ClassifierOutcomes ClassifierOutcomes { get; }
public string SchemaVersion { get; } public string SchemaVersion { get; }
/// <summary> /// <summary>
@@ -192,10 +189,6 @@ namespace AutopilotMonitor.DecisionCore.State
tenantId: tenantId, tenantId: tenantId,
stage: SessionStage.SessionStarted, stage: SessionStage.SessionStarted,
outcome: null, outcome: null,
enrollmentType: Hypothesis.UnknownInstance,
whiteGloveSealing: Hypothesis.UnknownInstance,
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
deviceOnlyDeployment: Hypothesis.UnknownInstance,
currentEnrollmentPhase: null, currentEnrollmentPhase: null,
deviceSetupEnteredUtc: null, deviceSetupEnteredUtc: null,
accountSetupEnteredUtc: null, accountSetupEnteredUtc: null,
@@ -205,19 +198,17 @@ namespace AutopilotMonitor.DecisionCore.State
helloResolvedUtc: null, helloResolvedUtc: null,
systemRebootUtc: null, systemRebootUtc: null,
helloOutcome: null, helloOutcome: null,
aadJoinedWithUser: null,
imeMatchedPatternId: null, imeMatchedPatternId: null,
shellCoreWhiteGloveSuccessSeen: null,
whiteGloveSealingPatternSeen: null,
userAadSignInCompleteUtc: null, userAadSignInCompleteUtc: null,
helloResolvedPart2Utc: null, helloResolvedPart2Utc: null,
desktopArrivedPart2Utc: null, desktopArrivedPart2Utc: null,
accountSetupCompletedPart2Utc: null, accountSetupCompletedPart2Utc: null,
skipUserEsp: null,
skipDeviceEsp: null,
deadlines: Array.Empty<ActiveDeadline>(), deadlines: Array.Empty<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1, lastAppliedSignalOrdinal: -1,
stepIndex: 0, stepIndex: 0,
appInstallFacts: AppInstallFacts.Empty); appInstallFacts: AppInstallFacts.Empty,
scenarioProfile: EnrollmentScenarioProfile.Empty,
scenarioObservations: EnrollmentScenarioObservations.Empty,
classifierOutcomes: ClassifierOutcomes.Empty);
} }
} }
@@ -23,10 +23,6 @@ namespace AutopilotMonitor.DecisionCore.State
TenantId = source.TenantId; TenantId = source.TenantId;
Stage = source.Stage; Stage = source.Stage;
Outcome = source.Outcome; Outcome = source.Outcome;
EnrollmentType = source.EnrollmentType;
WhiteGloveSealing = source.WhiteGloveSealing;
WhiteGlovePart2Completion = source.WhiteGlovePart2Completion;
DeviceOnlyDeployment = source.DeviceOnlyDeployment;
CurrentEnrollmentPhase = source.CurrentEnrollmentPhase; CurrentEnrollmentPhase = source.CurrentEnrollmentPhase;
DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc; DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc;
AccountSetupEnteredUtc = source.AccountSetupEnteredUtc; AccountSetupEnteredUtc = source.AccountSetupEnteredUtc;
@@ -36,20 +32,18 @@ namespace AutopilotMonitor.DecisionCore.State
HelloResolvedUtc = source.HelloResolvedUtc; HelloResolvedUtc = source.HelloResolvedUtc;
SystemRebootUtc = source.SystemRebootUtc; SystemRebootUtc = source.SystemRebootUtc;
HelloOutcome = source.HelloOutcome; HelloOutcome = source.HelloOutcome;
AadJoinedWithUser = source.AadJoinedWithUser;
ImeMatchedPatternId = source.ImeMatchedPatternId; ImeMatchedPatternId = source.ImeMatchedPatternId;
ShellCoreWhiteGloveSuccessSeen = source.ShellCoreWhiteGloveSuccessSeen;
WhiteGloveSealingPatternSeen = source.WhiteGloveSealingPatternSeen;
UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc; UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc;
HelloResolvedPart2Utc = source.HelloResolvedPart2Utc; HelloResolvedPart2Utc = source.HelloResolvedPart2Utc;
DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc; DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc;
AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc; AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc;
SkipUserEsp = source.SkipUserEsp;
SkipDeviceEsp = source.SkipDeviceEsp;
Deadlines = new List<ActiveDeadline>(source.Deadlines); Deadlines = new List<ActiveDeadline>(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal; LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex; StepIndex = source.StepIndex;
AppInstallFacts = source.AppInstallFacts; AppInstallFacts = source.AppInstallFacts;
ScenarioProfile = source.ScenarioProfile;
ScenarioObservations = source.ScenarioObservations;
ClassifierOutcomes = source.ClassifierOutcomes;
SchemaVersion = source.SchemaVersion; SchemaVersion = source.SchemaVersion;
} }
@@ -57,10 +51,6 @@ namespace AutopilotMonitor.DecisionCore.State
public string TenantId { get; set; } public string TenantId { get; set; }
public SessionStage Stage { get; set; } public SessionStage Stage { get; set; }
public SessionOutcome? Outcome { get; set; } public SessionOutcome? Outcome { get; set; }
public Hypothesis EnrollmentType { get; set; }
public Hypothesis WhiteGloveSealing { get; set; }
public Hypothesis WhiteGlovePart2Completion { get; set; }
public Hypothesis DeviceOnlyDeployment { get; set; }
public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; set; } public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; set; }
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; } public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; }
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; } public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
@@ -70,20 +60,18 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<DateTime>? HelloResolvedUtc { get; set; } public SignalFact<DateTime>? HelloResolvedUtc { get; set; }
public SignalFact<DateTime>? SystemRebootUtc { get; set; } public SignalFact<DateTime>? SystemRebootUtc { get; set; }
public SignalFact<string>? HelloOutcome { get; set; } public SignalFact<string>? HelloOutcome { get; set; }
public SignalFact<bool>? AadJoinedWithUser { get; set; }
public SignalFact<string>? ImeMatchedPatternId { get; set; } public SignalFact<string>? ImeMatchedPatternId { get; set; }
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; set; }
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; set; }
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; set; } public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; set; }
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; } public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; } public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; } public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; }
public SignalFact<bool>? SkipUserEsp { get; set; }
public SignalFact<bool>? SkipDeviceEsp { get; set; }
public List<ActiveDeadline> Deadlines { get; set; } public List<ActiveDeadline> Deadlines { get; set; }
public long LastAppliedSignalOrdinal { get; set; } public long LastAppliedSignalOrdinal { get; set; }
public int StepIndex { get; set; } public int StepIndex { get; set; }
public AppInstallFacts AppInstallFacts { get; set; } = AppInstallFacts.Empty; public AppInstallFacts AppInstallFacts { get; set; } = AppInstallFacts.Empty;
public EnrollmentScenarioProfile ScenarioProfile { get; set; } = EnrollmentScenarioProfile.Empty;
public EnrollmentScenarioObservations ScenarioObservations { get; set; } = EnrollmentScenarioObservations.Empty;
public ClassifierOutcomes ClassifierOutcomes { get; set; } = ClassifierOutcomes.Empty;
public string SchemaVersion { get; set; } public string SchemaVersion { get; set; }
// ---------- fluent helpers for the most common reducer operations ---------- // ---------- fluent helpers for the most common reducer operations ----------
@@ -133,16 +121,30 @@ namespace AutopilotMonitor.DecisionCore.State
return this; return this;
} }
public DecisionStateBuilder WithScenarioProfile(EnrollmentScenarioProfile profile)
{
ScenarioProfile = profile ?? throw new ArgumentNullException(nameof(profile));
return this;
}
public DecisionStateBuilder WithScenarioObservations(EnrollmentScenarioObservations observations)
{
ScenarioObservations = observations ?? throw new ArgumentNullException(nameof(observations));
return this;
}
public DecisionStateBuilder WithClassifierOutcomes(ClassifierOutcomes outcomes)
{
ClassifierOutcomes = outcomes ?? throw new ArgumentNullException(nameof(outcomes));
return this;
}
public DecisionState Build() => public DecisionState Build() =>
new DecisionState( new DecisionState(
sessionId: SessionId, sessionId: SessionId,
tenantId: TenantId, tenantId: TenantId,
stage: Stage, stage: Stage,
outcome: Outcome, outcome: Outcome,
enrollmentType: EnrollmentType,
whiteGloveSealing: WhiteGloveSealing,
whiteGlovePart2Completion: WhiteGlovePart2Completion,
deviceOnlyDeployment: DeviceOnlyDeployment,
currentEnrollmentPhase: CurrentEnrollmentPhase, currentEnrollmentPhase: CurrentEnrollmentPhase,
deviceSetupEnteredUtc: DeviceSetupEnteredUtc, deviceSetupEnteredUtc: DeviceSetupEnteredUtc,
accountSetupEnteredUtc: AccountSetupEnteredUtc, accountSetupEnteredUtc: AccountSetupEnteredUtc,
@@ -152,20 +154,18 @@ namespace AutopilotMonitor.DecisionCore.State
helloResolvedUtc: HelloResolvedUtc, helloResolvedUtc: HelloResolvedUtc,
systemRebootUtc: SystemRebootUtc, systemRebootUtc: SystemRebootUtc,
helloOutcome: HelloOutcome, helloOutcome: HelloOutcome,
aadJoinedWithUser: AadJoinedWithUser,
imeMatchedPatternId: ImeMatchedPatternId, imeMatchedPatternId: ImeMatchedPatternId,
shellCoreWhiteGloveSuccessSeen: ShellCoreWhiteGloveSuccessSeen,
whiteGloveSealingPatternSeen: WhiteGloveSealingPatternSeen,
userAadSignInCompleteUtc: UserAadSignInCompleteUtc, userAadSignInCompleteUtc: UserAadSignInCompleteUtc,
helloResolvedPart2Utc: HelloResolvedPart2Utc, helloResolvedPart2Utc: HelloResolvedPart2Utc,
desktopArrivedPart2Utc: DesktopArrivedPart2Utc, desktopArrivedPart2Utc: DesktopArrivedPart2Utc,
accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc, accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc,
skipUserEsp: SkipUserEsp,
skipDeviceEsp: SkipDeviceEsp,
deadlines: Deadlines.ToArray(), deadlines: Deadlines.ToArray(),
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal, lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
stepIndex: StepIndex, stepIndex: StepIndex,
appInstallFacts: AppInstallFacts, appInstallFacts: AppInstallFacts,
scenarioProfile: ScenarioProfile,
scenarioObservations: ScenarioObservations,
classifierOutcomes: ClassifierOutcomes,
schemaVersion: SchemaVersion); schemaVersion: SchemaVersion);
} }
} }
@@ -0,0 +1,20 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Directory-join method observed at session start. Codex follow-up #5 —
/// dimension of <see cref="EnrollmentScenarioProfile"/>. Derived from the
/// <c>isHybridJoin</c> payload on the <c>SessionStarted</c> signal
/// (<c>EnrollmentRegistryDetector.DetectHybridJoin</c>).
/// <para>
/// This is NOT the same concept as the legacy <c>AadJoinedWithUser</c> fact, which
/// tracked a late-AADJ user-presence signal for the WhiteGlove hard-excluder weight.
/// That observation lives in <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/>.
/// </para>
/// </summary>
public enum EnrollmentJoinMode
{
Unknown = 0,
AzureAdJoin = 1,
HybridAzureAdJoin = 2,
}
}
@@ -0,0 +1,21 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// High-level Autopilot enrollment mode inferred from registry + signals.
/// Codex follow-up #5 — dimension of <see cref="EnrollmentScenarioProfile"/>.
/// <para>
/// <see cref="Classic"/>, <see cref="SelfDeploying"/> and <see cref="WhiteGlove"/> are
/// the three v1 sub-modes the reducer distinguishes through later signals (the
/// <c>SessionStarted</c> registry probe only separates v1/v2). <see cref="DevicePreparation"/>
/// is Windows Autopilot Device Preparation (v2 / WDP) — no ESP, no pre-reboot user stage.
/// </para>
/// </summary>
public enum EnrollmentMode
{
Unknown = 0,
Classic = 1,
DevicePreparation = 2,
SelfDeploying = 3,
WhiteGlove = 4,
}
}
@@ -0,0 +1,120 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Raw per-signal observations that feed the WhiteGlove sealing classifier and downstream
/// guards. Codex follow-up #5 — replaces the legacy per-flag <see cref="SignalFact{T}"/>
/// fields (<c>ShellCoreWhiteGloveSuccessSeen</c>, <c>WhiteGloveSealingPatternSeen</c>,
/// <c>AadJoinedWithUser</c>, <c>SkipUserEsp</c>, <c>SkipDeviceEsp</c>) with a single aggregate.
/// These are **evidence**, not classification — the derived classification lives in
/// <see cref="EnrollmentScenarioProfile"/>.
/// <para>
/// <b>Invariants</b>:
/// <list type="bullet">
/// <item>Immutable; the <c>With…</c> methods return new instances.</item>
/// <item>Set-once semantics for Boolean flags: once observed, later identical signals are
/// no-ops (the first-sighting ordinal is preserved as evidence).</item>
/// <item><see cref="AadUserJoinWithUserObserved"/> is the late-AADJ user-presence flag
/// (payload <c>aadJoinedWithUser</c>) — NOT the <see cref="EnrollmentJoinMode"/>.
/// See <see cref="EnrollmentJoinMode"/> remarks.</item>
/// <item><see cref="SkipUserEsp"/> / <see cref="SkipDeviceEsp"/> are the raw half-facts
/// from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>. The derived
/// <see cref="EnrollmentScenarioProfile.EspConfig"/> is only set when BOTH halves
/// are observed (signals can arrive partial — first skipUser-only, later skipDevice).</item>
/// </list>
/// </para>
/// </summary>
public sealed class EnrollmentScenarioObservations
{
public static readonly EnrollmentScenarioObservations Empty = new EnrollmentScenarioObservations(
shellCoreWhiteGloveSuccessSeen: null,
whiteGloveSealingPatternSeen: null,
aadUserJoinWithUserObserved: null,
skipUserEsp: null,
skipDeviceEsp: null);
public EnrollmentScenarioObservations(
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
SignalFact<bool>? whiteGloveSealingPatternSeen,
SignalFact<bool>? aadUserJoinWithUserObserved,
SignalFact<bool>? skipUserEsp,
SignalFact<bool>? skipDeviceEsp)
{
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
AadUserJoinWithUserObserved = aadUserJoinWithUserObserved;
SkipUserEsp = skipUserEsp;
SkipDeviceEsp = skipDeviceEsp;
}
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> has fired.</summary>
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; }
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired.</summary>
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
/// <summary>
/// Payload-carrying observation from <see cref="Signals.DecisionSignalKind.AadUserJoinedLate"/>.
/// <c>true</c> = late AADJ observed with a user-side principal (hard-excluder for
/// the WhiteGlove classifier); <c>false</c> = late AADJ observed but device-only.
/// Independent of <see cref="EnrollmentJoinMode"/>, which reflects the
/// <c>SessionStarted</c> registry hint.
/// </summary>
public SignalFact<bool>? AadUserJoinWithUserObserved { get; }
/// <summary>Raw payload half-fact from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.</summary>
public SignalFact<bool>? SkipUserEsp { get; }
/// <summary>Raw payload half-fact from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.</summary>
public SignalFact<bool>? SkipDeviceEsp { get; }
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
ShellCoreWhiteGloveSuccessSeen != null
? this
: new EnrollmentScenarioObservations(
new SignalFact<bool>(true, sourceSignalOrdinal),
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp);
public EnrollmentScenarioObservations WithWhiteGloveSealingPatternSeen(long sourceSignalOrdinal) =>
WhiteGloveSealingPatternSeen != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
new SignalFact<bool>(true, sourceSignalOrdinal),
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp);
public EnrollmentScenarioObservations WithAadUserJoinWithUserObserved(bool value, long sourceSignalOrdinal) =>
AadUserJoinWithUserObserved != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
new SignalFact<bool>(value, sourceSignalOrdinal),
SkipUserEsp,
SkipDeviceEsp);
public EnrollmentScenarioObservations WithSkipUserEsp(bool value, long sourceSignalOrdinal) =>
SkipUserEsp != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
new SignalFact<bool>(value, sourceSignalOrdinal),
SkipDeviceEsp);
public EnrollmentScenarioObservations WithSkipDeviceEsp(bool value, long sourceSignalOrdinal) =>
SkipDeviceEsp != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
new SignalFact<bool>(value, sourceSignalOrdinal));
}
}
@@ -0,0 +1,87 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Typed aggregate of the derived enrollment-scenario classification. Codex follow-up #5 —
/// replaces the legacy <c>Hypothesis EnrollmentType</c> and the <c>SkipUserEsp</c> /
/// <c>SkipDeviceEsp</c> facts, and adds an explicit <see cref="JoinMode"/> +
/// <see cref="PreProvisioningSide"/> dimension. This is the **semantic truth** of the
/// enrollment — raw per-signal evidence lives in <see cref="EnrollmentScenarioObservations"/>
/// and classifier verdicts live in <see cref="ClassifierOutcomes"/>.
/// <para>
/// <b>Invariants</b>:
/// <list type="bullet">
/// <item>Immutable; use <see cref="With"/> to produce a mutated copy.</item>
/// <item>Confidence monotonicity is the caller's responsibility (see <see cref="EnrollmentScenarioProfileUpdater"/>) — the type itself does not enforce it, keeping deserialization after recovery straightforward.</item>
/// <item><see cref="EvidenceOrdinal"/> is the <see cref="Signals.DecisionSignal.SessionSignalOrdinal"/> of the last signal that strengthened the profile; <c>-1</c> when <see cref="Empty"/>.</item>
/// </list>
/// </para>
/// </summary>
public sealed class EnrollmentScenarioProfile
{
public static readonly EnrollmentScenarioProfile Empty = new EnrollmentScenarioProfile(
mode: EnrollmentMode.Unknown,
joinMode: EnrollmentJoinMode.Unknown,
espConfig: EspConfig.Unknown,
preProvisioningSide: PreProvisioningSide.None,
confidence: ProfileConfidence.Low,
evidenceOrdinal: -1,
reason: null);
public EnrollmentScenarioProfile(
EnrollmentMode mode,
EnrollmentJoinMode joinMode,
EspConfig espConfig,
PreProvisioningSide preProvisioningSide,
ProfileConfidence confidence,
long evidenceOrdinal,
string? reason)
{
Mode = mode;
JoinMode = joinMode;
EspConfig = espConfig;
PreProvisioningSide = preProvisioningSide;
Confidence = confidence;
EvidenceOrdinal = evidenceOrdinal;
Reason = reason;
}
public EnrollmentMode Mode { get; }
public EnrollmentJoinMode JoinMode { get; }
public EspConfig EspConfig { get; }
public PreProvisioningSide PreProvisioningSide { get; }
public ProfileConfidence Confidence { get; }
/// <summary>Signal ordinal that most recently strengthened the profile, or <c>-1</c> if never.</summary>
public long EvidenceOrdinal { get; }
/// <summary>Short token describing the last strengthening reason (analog to <see cref="Hypothesis.Reason"/>).</summary>
public string? Reason { get; }
/// <summary>
/// Produce a copy with the specified dimensions replaced. Omitting a parameter keeps the
/// current value — including <see cref="Reason"/>, which means callers cannot clear the
/// reason to <c>null</c> via <see cref="With"/> (intentional: once set, reasons are
/// historical evidence).
/// </summary>
public EnrollmentScenarioProfile With(
EnrollmentMode? mode = null,
EnrollmentJoinMode? joinMode = null,
EspConfig? espConfig = null,
PreProvisioningSide? preProvisioningSide = null,
ProfileConfidence? confidence = null,
long? evidenceOrdinal = null,
string? reason = null) =>
new EnrollmentScenarioProfile(
mode ?? Mode,
joinMode ?? JoinMode,
espConfig ?? EspConfig,
preProvisioningSide ?? PreProvisioningSide,
confidence ?? Confidence,
evidenceOrdinal ?? EvidenceOrdinal,
reason ?? Reason);
}
}
@@ -0,0 +1,288 @@
using System;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Pure static mutator that maps a <see cref="DecisionSignal"/> to a new
/// <see cref="EnrollmentScenarioProfile"/>. Codex follow-up #5 — keeps the signal →
/// profile rules out of the reducer handlers so they can be unit-tested in isolation
/// from the full reducer pipeline.
/// <para>
/// <b>Monotonic confidence</b>: these helpers never regress <see cref="EnrollmentScenarioProfile.Confidence"/>
/// or <see cref="EnrollmentScenarioProfile.Mode"/>. A new signal can only raise confidence
/// or swap mode when the current classification is <see cref="EnrollmentMode.Unknown"/> /
/// below the incoming strength. That keeps replay deterministic even if signals arrive
/// out of order during recovery.
/// </para>
/// </summary>
public static class EnrollmentScenarioProfileUpdater
{
// ================================================================== SessionStarted
/// <summary>
/// Seed the profile from a <see cref="DecisionSignalKind.SessionStarted"/> signal.
/// Payload keys: <see cref="SignalPayloadKeys.EnrollmentType"/> (literal "v1" / "v2"
/// from the registry detector), <see cref="SignalPayloadKeys.IsHybridJoin"/>.
/// </summary>
public static EnrollmentScenarioProfile ApplySessionStarted(
EnrollmentScenarioProfile current,
DecisionSignal signal)
{
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
var mode = current.Mode;
var joinMode = current.JoinMode;
var confidence = current.Confidence;
var reason = current.Reason;
var evidenceOrdinal = current.EvidenceOrdinal;
var changed = false;
if (signal.Payload != null)
{
if (signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentType, out var rawType)
&& !string.IsNullOrEmpty(rawType)
&& current.Mode == EnrollmentMode.Unknown)
{
// Registry detector distinguishes v1 from v2 only. v1 stays Unknown here —
// the finer UserDriven / SelfDeploying / WhiteGlove classification comes
// from later signals (AccountSetup phase, ImeUserSessionCompleted,
// DeviceSetupProvisioningComplete, classifier verdicts).
if (string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase))
{
mode = EnrollmentMode.DevicePreparation;
confidence = Max(confidence, ProfileConfidence.Medium);
reason = "session_started:v2";
changed = true;
}
else
{
// v1 (or any other): keep Mode=Unknown but record the reason.
reason = $"session_started:{rawType}";
changed = true;
}
}
if (signal.Payload.TryGetValue(SignalPayloadKeys.IsHybridJoin, out var rawHybrid)
&& bool.TryParse(rawHybrid, out var isHybrid)
&& current.JoinMode == EnrollmentJoinMode.Unknown)
{
joinMode = isHybrid ? EnrollmentJoinMode.HybridAzureAdJoin : EnrollmentJoinMode.AzureAdJoin;
changed = true;
}
}
if (!changed) return current;
return current.With(
mode: mode,
joinMode: joinMode,
confidence: confidence,
reason: reason,
evidenceOrdinal: signal.SessionSignalOrdinal);
}
// ================================================================== EspConfigDetected
/// <summary>
/// Apply the <c>skipUserEsp</c> / <c>skipDeviceEsp</c> payload on
/// <see cref="DecisionSignalKind.EspConfigDetected"/> to the observations +
/// derived profile. Returns both new values as a tuple — the signal can arrive
/// **partially** (e.g. first call with <c>skipUser</c> only, second with
/// <c>skipDevice</c>), so the updater fills whichever half is present + parseable
/// and only derives <see cref="EnrollmentScenarioProfile.EspConfig"/> once BOTH
/// halves are known.
/// <para>
/// Set-once: existing <see cref="EnrollmentScenarioObservations.SkipUserEsp"/> /
/// <see cref="EnrollmentScenarioObservations.SkipDeviceEsp"/> facts are never
/// overwritten by a later signal with the same (or different) payload.
/// </para>
/// </summary>
public static (EnrollmentScenarioProfile profile, EnrollmentScenarioObservations observations) ApplyEspConfigDetected(
EnrollmentScenarioProfile currentProfile,
EnrollmentScenarioObservations currentObservations,
DecisionSignal signal)
{
if (currentProfile == null) throw new ArgumentNullException(nameof(currentProfile));
if (currentObservations == null) throw new ArgumentNullException(nameof(currentObservations));
if (signal == null) throw new ArgumentNullException(nameof(signal));
var newObservations = currentObservations;
if (signal.Payload != null)
{
if (signal.Payload.TryGetValue(SignalPayloadKeys.SkipUserEsp, out var rawUser)
&& bool.TryParse(rawUser, out var skipUser))
{
newObservations = newObservations.WithSkipUserEsp(skipUser, signal.SessionSignalOrdinal);
}
if (signal.Payload.TryGetValue(SignalPayloadKeys.SkipDeviceEsp, out var rawDevice)
&& bool.TryParse(rawDevice, out var skipDevice))
{
newObservations = newObservations.WithSkipDeviceEsp(skipDevice, signal.SessionSignalOrdinal);
}
}
var newProfile = currentProfile;
if (currentProfile.EspConfig == EspConfig.Unknown
&& newObservations.SkipUserEsp != null
&& newObservations.SkipDeviceEsp != null)
{
var derived = DeriveEspConfig(
newObservations.SkipUserEsp.Value,
newObservations.SkipDeviceEsp.Value);
newProfile = currentProfile.With(
espConfig: derived,
confidence: Max(currentProfile.Confidence, ProfileConfidence.Medium),
reason: $"esp_config_detected:{derived}",
evidenceOrdinal: signal.SessionSignalOrdinal);
}
return (newProfile, newObservations);
}
internal static EspConfig DeriveEspConfig(bool skipUser, bool skipDevice) =>
(skipUser, skipDevice) switch
{
(false, false) => EspConfig.FullEsp,
(true, false) => EspConfig.DeviceEspOnly,
(false, true) => EspConfig.UserEspOnly,
(true, true) => EspConfig.NoEsp,
};
// ================================================================== EspPhaseChanged
/// <summary>
/// Observing <see cref="Shared.Models.EnrollmentPhase.AccountSetup"/> for the first time
/// is the canonical Classic UserDriven-v1 tell (mirrors the legacy
/// <c>account_setup_observed</c> hypothesis update). Only promotes Mode when it is still
/// <see cref="EnrollmentMode.Unknown"/> — a WhiteGlove or SelfDeploying classification
/// from an earlier signal wins.
/// </summary>
public static EnrollmentScenarioProfile ApplyAccountSetupObserved(
EnrollmentScenarioProfile current,
DecisionSignal signal)
{
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
if (current.Mode != EnrollmentMode.Unknown) return current;
return current.With(
mode: EnrollmentMode.Classic,
confidence: Max(current.Confidence, ProfileConfidence.Medium),
reason: "account_setup_observed",
evidenceOrdinal: signal.SessionSignalOrdinal);
}
// ================================================================== ImeUserSessionCompleted
/// <summary>
/// <see cref="DecisionSignalKind.ImeUserSessionCompleted"/> is a strong UserDriven-v1
/// indicator. Upgrades Mode to <see cref="EnrollmentMode.Classic"/> at
/// <see cref="ProfileConfidence.High"/>, UNLESS a prior signal has already confirmed a
/// different mode at High confidence (monotonic rule).
/// </summary>
public static EnrollmentScenarioProfile ApplyImeUserSessionCompleted(
EnrollmentScenarioProfile current,
DecisionSignal signal)
{
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
if (current.Confidence == ProfileConfidence.High && current.Mode != EnrollmentMode.Classic
&& current.Mode != EnrollmentMode.Unknown)
{
return current;
}
return current.With(
mode: EnrollmentMode.Classic,
confidence: ProfileConfidence.High,
reason: "ime_user_session_completed",
evidenceOrdinal: signal.SessionSignalOrdinal);
}
// ================================================================== DeviceSetupProvisioningComplete
/// <summary>
/// <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/> without a preceding
/// AccountSetup is a strong SelfDeploying-v1 indicator (caller must guard that invariant).
/// Upgrades Mode to <see cref="EnrollmentMode.SelfDeploying"/> at
/// <see cref="ProfileConfidence.High"/>.
/// </summary>
public static EnrollmentScenarioProfile ApplyDeviceSetupProvisioningComplete(
EnrollmentScenarioProfile current,
DecisionSignal signal)
{
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
if (current.Confidence == ProfileConfidence.High && current.Mode != EnrollmentMode.SelfDeploying
&& current.Mode != EnrollmentMode.Unknown)
{
return current;
}
return current.With(
mode: EnrollmentMode.SelfDeploying,
confidence: ProfileConfidence.High,
reason: "selfdeploying_provisioning_complete",
evidenceOrdinal: signal.SessionSignalOrdinal);
}
// ================================================================== WhiteGlove verdict
/// <summary>
/// When the WhiteGlove sealing classifier issues a <see cref="HypothesisLevel.Confirmed"/>
/// verdict, the profile mirrors the decision: Mode=<see cref="EnrollmentMode.WhiteGlove"/>
/// at <see cref="ProfileConfidence.High"/>, PreProvisioningSide=<see cref="PreProvisioningSide.Technician"/>
/// (Part 1 is by definition the technician side). Non-Confirmed verdicts leave the profile
/// alone — the verdict itself is stored in <see cref="ClassifierOutcomes.WhiteGloveSealing"/>.
/// </summary>
public static EnrollmentScenarioProfile ApplyWhiteGloveSealingConfirmed(
EnrollmentScenarioProfile current,
DecisionSignal signal)
{
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
return current.With(
mode: EnrollmentMode.WhiteGlove,
preProvisioningSide: PreProvisioningSide.Technician,
confidence: ProfileConfidence.High,
reason: "classifier_whiteglove_sealing_confirmed",
evidenceOrdinal: signal.SessionSignalOrdinal);
}
// ================================================================== AadUserJoinedLate
/// <summary>
/// <see cref="DecisionSignalKind.AadUserJoinedLate"/> is a classifier-state update only
/// (see <c>feedback_aad_joined_late_not_completion</c>). It does NOT touch
/// <see cref="EnrollmentScenarioProfile.JoinMode"/> — that comes from
/// <c>SessionStarted.payload.isHybridJoin</c>. The payload-carrying presence flag is
/// stored in <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/>;
/// the profile only records the reason annotation.
/// </summary>
public static EnrollmentScenarioProfile ApplyAadUserJoinedLate(
EnrollmentScenarioProfile current,
DecisionSignal signal,
bool withUser)
{
if (current == null) throw new ArgumentNullException(nameof(current));
if (signal == null) throw new ArgumentNullException(nameof(signal));
return current.With(
reason: $"late_aadj_observed:withUser={(withUser ? "true" : "false")}",
evidenceOrdinal: signal.SessionSignalOrdinal);
}
// ================================================================== helpers
internal static ProfileConfidence Max(ProfileConfidence a, ProfileConfidence b) =>
a >= b ? a : b;
}
}
@@ -0,0 +1,22 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// ESP (Enrollment Status Page) phase coverage for this enrollment. Codex follow-up #5 —
/// dimension of <see cref="EnrollmentScenarioProfile"/>. Derived from the
/// <c>skipUserEsp</c> / <c>skipDeviceEsp</c> payload on the <c>EspConfigDetected</c>
/// signal. Replaces the legacy <c>SkipUserEsp</c> / <c>SkipDeviceEsp</c>
/// <see cref="SignalFact{T}"/> facts.
/// </summary>
public enum EspConfig
{
Unknown = 0,
/// <summary>Both Device-ESP and Account-ESP run (the default).</summary>
FullEsp = 1,
/// <summary>Device-ESP is skipped; Account-ESP still runs (<c>skipDeviceEsp=true</c>).</summary>
UserEspOnly = 2,
/// <summary>Account-ESP is skipped; Device-ESP still runs (<c>skipUserEsp=true</c>).</summary>
DeviceEspOnly = 3,
/// <summary>Both phases are skipped (<c>skipUserEsp=true</c> AND <c>skipDeviceEsp=true</c>).</summary>
NoEsp = 4,
}
}
@@ -0,0 +1,18 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Identifies the pre-provisioning side when the enrollment is a WhiteGlove flow.
/// Codex follow-up #5 — dimension of <see cref="EnrollmentScenarioProfile"/>.
/// <list type="bullet">
/// <item><see cref="None"/> — non-WhiteGlove flow or pre-provisioning side not yet known.</item>
/// <item><see cref="Technician"/> — Part-1 (pre-reboot, technician at IT bench).</item>
/// <item><see cref="User"/> — Part-2 (post-reboot, end-user sign-in at their workplace).</item>
/// </list>
/// </summary>
public enum PreProvisioningSide
{
None = 0,
Technician = 1,
User = 2,
}
}
@@ -0,0 +1,19 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Overall confidence in the current <see cref="EnrollmentScenarioProfile"/> classification.
/// Codex follow-up #5. Monotonic by convention: the updater never regresses confidence —
/// a later signal with weaker evidence cannot overwrite a stronger classification.
/// <list type="bullet">
/// <item><see cref="Low"/> — initial / registry-derived hint (e.g. <c>SessionStarted</c> only).</item>
/// <item><see cref="Medium"/> — corroborating signal observed (e.g. ESP phase change, profile read).</item>
/// <item><see cref="High"/> — decisive signal (e.g. IME pattern match, classifier Confirmed).</item>
/// </list>
/// </summary>
public enum ProfileConfidence
{
Low = 0,
Medium = 1,
High = 2,
}
}