From 579291272e8dfb91cdf0aa188dd6038d02fe9a81 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 27 Apr 2026 09:19:34 +0200 Subject: [PATCH] fix(v2): split EnrollmentFactsObserved from SessionStarted to fix JoinMode race V2 tracker / Backend-register-session race could land SessionStarted after ESP/Tracker signals had already advanced Stage past SessionStarted. The HandleSessionStartedV1 dead-end-Wache then swallowed the registry-derived profile facts (enrollmentType + isHybridJoin), leaving JoinMode=Unknown on every affected enrollment_complete payload (Session 10c8e0bf). Root cause was a concept mix in SessionStarted: a lifecycle anchor doing double duty as a static-facts seed. Split into two signals: - SessionStarted: pure lifecycle anchor (Stage / ClassifierTick / step). - EnrollmentFactsObserved: stage-agnostic, idempotent, monotonic facts carrier. New reducer handler delegates to ApplyEnrollmentFactsObserved (renamed from ApplySessionStarted, with set-once reason for v1 to honour the documented idempotency contract). AwaitEnrollment is transparently covered: AgentBootstrap blocks until the MDM cert exists, so the registry is guaranteed populated by the time PostEnrollmentFactsObserved runs; a single post per agent run suffices. Tests: - DecisionCore: 234/234 (4 new fact-handler tests + idempotency + monotonicity) - Agent.V2.Core: 771/771 (3 new producer tests, 1 reshaped) - Agent.Core (V1): 507/507 sanity-check unchanged Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PostEnrollmentFactsObservedSignalTests.cs | 95 +++++++++++++++++++ .../Program/PostSessionStartedSignalTests.cs | 12 ++- .../Runtime/AgentRuntimeHost.cs | 6 ++ .../Runtime/LifecycleEmitters.cs | 63 +++++++++++- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../EnrollmentScenarioProfileUpdaterTests.cs | 90 +++++++++++++++--- .../Engine/DecisionEngine.Classic.cs | 5 +- .../Engine/DecisionEngine.Shared.cs | 49 ++++++++-- .../Engine/DecisionEngine.cs | 1 + .../Signals/DecisionSignalKind.cs | 18 ++++ .../State/EnrollmentJoinMode.cs | 7 +- .../State/EnrollmentScenarioProfileUpdater.cs | 52 +++++++--- .../enrollment-facts-observed-v1.json | 11 +++ 14 files changed, 370 insertions(+), 43 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs create mode 100644 tests/fixtures/signal-kinds/enrollment-facts-observed-v1.json diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs new file mode 100644 index 00000000..42a4a659 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// V2 race-fix (10c8e0bf debrief, 2026-04-26) — verifies that + /// + /// emits a signal carrying the + /// registry-derived enrollment facts so the reducer can seed + /// via the stage-agnostic facts handler. + /// + public sealed class PostEnrollmentFactsObservedSignalTests + { + private static AgentLogger NewLogger(string path) + => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info); + + [Fact] + public void Posts_enrollment_facts_observed_kind_with_default_schema_version() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var logger = NewLogger(tmp.Path); + + AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger); + + Assert.Single(sink.Posted); + var posted = sink.Posted[0]; + Assert.Equal(DecisionSignalKind.EnrollmentFactsObserved, posted.Kind); + Assert.Equal(1, posted.KindSchemaVersion); + Assert.Equal("Program.RunAgent", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Synthetic, posted.Evidence.Kind); + Assert.Equal("enrollment_registry_facts_read", posted.Evidence.Identifier); + } + + [Fact] + public void Payload_carries_enrollment_type_and_hybrid_join_keys() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var logger = NewLogger(tmp.Path); + + AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger); + + var payload = sink.Posted[0].Payload; + Assert.NotNull(payload); + // Test runs off-device — registry detector returns the safe defaults + // ("v1" / "false") since the Autopilot keys are absent on the build host. + // The contract under test is the payload SHAPE, not the detected values. + Assert.Contains("enrollmentType", (IDictionary)payload!); + Assert.Contains("isHybridJoin", (IDictionary)payload); + // Value sanity — defaults must be parseable booleans / known type literals. + var hybrid = payload["isHybridJoin"]; + Assert.True(hybrid == "true" || hybrid == "false"); + var type = payload["enrollmentType"]; + Assert.True(type == "v1" || type == "v2"); + } + + [Fact] + public void Swallows_sink_exceptions() + { + // Helper runs on the hot startup path — a misbehaving sink must not crash the agent. + using var tmp = new TempDirectory(); + var sink = new ThrowingSink(); + var logger = NewLogger(tmp.Path); + + var ex = Record.Exception(() => + AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger)); + + Assert.Null(ex); + } + + private sealed class ThrowingSink : ISignalIngressSink + { + public void Post( + DecisionSignalKind kind, + System.DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1, + object? typedPayload = null) + { + throw new System.InvalidOperationException("ingress unavailable"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs index 4a3a2cf5..1aaa3c44 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs @@ -74,8 +74,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program } [Fact] - public void Payload_includes_registration_metadata_keys() + public void Payload_carries_only_lifecycle_anchor_metadata() { + // V2 race-fix (10c8e0bf debrief, 2026-04-26) — enrollmentType / isHybridJoin + // moved to DecisionSignalKind.EnrollmentFactsObserved (covered by + // PostEnrollmentFactsObservedSignalTests). SessionStarted is now a pure + // lifecycle anchor with only the registration-metadata payload. using var tmp = new TempDirectory(); var sink = new FakeSignalIngressSink(); var logger = NewLogger(tmp.Path); @@ -87,9 +91,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program Assert.NotNull(payload); Assert.Equal("CorporateIdentifier", payload!["validatedBy"]); Assert.Equal("true", payload["isBootstrapSession"]); - Assert.Contains("enrollmentType", (System.Collections.Generic.IDictionary)payload); - Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary)payload); - Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary)payload); + Assert.Equal("1.2.3.4", payload["agentVersion"]); + Assert.DoesNotContain("enrollmentType", (System.Collections.Generic.IDictionary)payload); + Assert.DoesNotContain("isHybridJoin", (System.Collections.Generic.IDictionary)payload); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs index 579600d5..d6ff10f5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs @@ -294,8 +294,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime // - Admin preemption: the AdminPreemptionDetected signal below // drives the session straight to a terminal stage; SessionStarted // first would be noise. + // V2 race-fix (10c8e0bf debrief, 2026-04-26): EnrollmentFactsObserved + // is posted first so the registry-derived profile facts (enrollmentType + // + isHybridJoin) seed ScenarioProfile via a stage-agnostic reducer + // path. The reducer correctness no longer depends on this ordering — + // the order here exists only for Inspector-timeline readability. if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction)) { + LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, logger); LifecycleEmitters.PostSessionStarted(orchestrator.IngressSink, registrationResult, agentConfig, agentVersion, logger); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index e0802647..f8d00762 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -175,10 +175,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime { try { + // V2 race-fix (10c8e0bf debrief, 2026-04-26) — registry-derived facts + // (enrollmentType + isHybridJoin) moved to PostEnrollmentFactsObserved + // because the legacy SessionStarted handler had a Stage-Wache that + // dropped the profile update whenever any other signal had already + // advanced Stage past SessionStarted by the time the post landed. + // SessionStarted now carries only lifecycle-anchor metadata. var payload = new Dictionary(StringComparer.Ordinal) { - ["enrollmentType"] = EnrollmentRegistryDetector.DetectEnrollmentType(), - ["isHybridJoin"] = EnrollmentRegistryDetector.DetectHybridJoin() ? "true" : "false", ["validatedBy"] = registrationResult.ValidatedBy.ToString(), ["agentVersion"] = agentVersion, ["isBootstrapSession"] = agentConfig.UseBootstrapTokenAuth ? "true" : "false", @@ -196,7 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime evidence: evidence, payload: payload); - logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy}, enrollmentType={payload["enrollmentType"]})."); + logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy})."); } catch (Exception ex) { @@ -204,6 +208,59 @@ namespace AutopilotMonitor.Agent.V2.Runtime } } + /// + /// V2 race-fix (10c8e0bf debrief, 2026-04-26) — post a + /// signal carrying the + /// registry-derived enrollment facts (enrollmentType + isHybridJoin) + /// so the reducer can seed via the + /// stage-agnostic HandleEnrollmentFactsObservedV1 handler. + /// + /// Posted immediately before so the Inspector + /// timeline reads naturally (facts → anchor) — but the reducer correctness does + /// not depend on the order, which is the whole point of the new signal. + /// + /// + /// AwaitEnrollment-mode is transparently covered: the bootstrap layer + /// (AgentBootstrap) blocks until the MDM certificate is present, so by + /// the time this method runs the Autopilot policy registry is guaranteed to + /// hold the final enrollment values — a single post per agent run is enough. + /// + /// + public static void PostEnrollmentFactsObserved( + ISignalIngressSink ingressSink, + AgentLogger logger) + { + try + { + var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(); + var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(); + + var payload = new Dictionary(StringComparer.Ordinal) + { + ["enrollmentType"] = enrollmentType, + ["isHybridJoin"] = isHybridJoin ? "true" : "false", + }; + + var evidence = new Evidence( + kind: EvidenceKind.Synthetic, + identifier: "enrollment_registry_facts_read", + summary: "Registry-derived enrollment facts read for scenario-profile seeding."); + + ingressSink.Post( + kind: DecisionSignalKind.EnrollmentFactsObserved, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "Program.RunAgent", + evidence: evidence, + payload: payload); + + logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin})."); + } + catch (Exception ex) + { + logger.Warning($"EnrollmentFactsObserved post failed: {ex.Message}"); + } + } + /// /// V2 parity — post when the /// register-session response carried an AdminAction. The reducer transitions diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 77851f13..6f8a8c5e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -382 +390 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 3fe2180a..03183b0a 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1186 +1187 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs index 90b850c8..70cf8a48 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs @@ -17,13 +17,20 @@ namespace AutopilotMonitor.DecisionCore.Tests.State { private static readonly DateTime FixedUtc = new DateTime(2026, 4, 24, 12, 0, 0, DateTimeKind.Utc); - // ================================================================== SessionStarted + // ================================================================== EnrollmentFactsObserved + + // V2 race-fix (10c8e0bf debrief, 2026-04-26) — these tests cover the new + // dedicated facts signal that replaced the profile-seeding side of + // SessionStarted. Behaviour parity with the legacy tests is preserved + // (same payload keys, same Mode/JoinMode mapping); the reason token now + // reads `enrollment_facts_observed:*` so it can be told apart in the + // Inspector trace. [Fact] - public void SessionStarted_v2_setsDevicePreparation_mediumConfidence() + public void EnrollmentFactsObserved_v2_setsDevicePreparation_mediumConfidence() { var signal = MakeSignal( - DecisionSignalKind.SessionStarted, + DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: new Dictionary { @@ -31,21 +38,21 @@ namespace AutopilotMonitor.DecisionCore.Tests.State [SignalPayloadKeys.IsHybridJoin] = "false", }); - var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted( + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( 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("enrollment_facts_observed:v2", profile.Reason); Assert.Equal(0, profile.EvidenceOrdinal); } [Fact] - public void SessionStarted_v1_leavesModeUnknown_recordsReason() + public void EnrollmentFactsObserved_v1_leavesModeUnknown_recordsReason() { var signal = MakeSignal( - DecisionSignalKind.SessionStarted, + DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: new Dictionary { @@ -53,26 +60,85 @@ namespace AutopilotMonitor.DecisionCore.Tests.State [SignalPayloadKeys.IsHybridJoin] = "true", }); - var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted( + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( 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); + Assert.Equal("enrollment_facts_observed:v1", profile.Reason); } [Fact] - public void SessionStarted_emptyPayload_returnsSameInstance() + public void EnrollmentFactsObserved_emptyPayload_returnsSameInstance() { - var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0, payload: null); + var signal = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: null); - var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted( + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( EnrollmentScenarioProfile.Empty, signal); Assert.Same(EnrollmentScenarioProfile.Empty, profile); } + [Fact] + public void EnrollmentFactsObserved_repeatPostWithSameFacts_isIdempotent() + { + var first = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + }); + var second = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 7, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + }); + + var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, first); + var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second); + + // Idempotent: second post is a no-op on the profile, including the EvidenceOrdinal — + // the Unknown-guards short-circuit because every dimension is already set. + Assert.Same(p1, p2); + Assert.Equal(EnrollmentJoinMode.AzureAdJoin, p2.JoinMode); + Assert.Equal(0, p2.EvidenceOrdinal); + } + + [Fact] + public void EnrollmentFactsObserved_laterSignalCannotRegressJoinMode() + { + // Initial post sets HADJ. + var firstSignal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.IsHybridJoin] = "true", + }); + var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, firstSignal); + Assert.Equal(EnrollmentJoinMode.HybridAzureAdJoin, p1.JoinMode); + + // A later, flaky re-read claims AADJ — must NOT overwrite the already-known value. + var flapped = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 12, + payload: new Dictionary + { + [SignalPayloadKeys.IsHybridJoin] = "false", + }); + var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, flapped); + + Assert.Equal(EnrollmentJoinMode.HybridAzureAdJoin, p2.JoinMode); + } + // ================================================================== EspConfigDetected [Fact] diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index f2db5b39..9c7da059 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -320,8 +320,9 @@ namespace AutopilotMonitor.DecisionCore.Engine /// terminal event is emitted. Codex follow-up #5: the user-presence flag lives in /// (NOT /// — that is strictly the - /// SessionStarted.payload.isHybridJoin value), and the profile's - /// is annotated without touching Mode. + /// isHybridJoin + /// payload), and the profile's is + /// annotated without touching Mode. /// /// private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal) diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 842e3e19..5ac2f316 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -53,18 +53,18 @@ namespace AutopilotMonitor.DecisionCore.Engine // periodically from the very start of the session — Plan §4.x M4.4.4. var classifierTick = BuildClassifierTickDeadline(signal.OccurredAtUtc); - // Codex follow-up #5 — seed the scenario profile from the registry-detector payload - // (enrollmentType + isHybridJoin). Stage stays SessionStarted; this transition is the - // "we saw the start" anchor. + // V2 race-fix (10c8e0bf debrief, 2026-04-26): SessionStarted is now a pure + // lifecycle anchor (stage / classifier-tick deadline / step bookkeeping). + // The registry-derived profile facts (enrollmentType + isHybridJoin) flow + // through the dedicated DecisionSignalKind.EnrollmentFactsObserved signal, + // which is stage-agnostic and therefore immune to the V2-Tracker / + // Backend-register-session race that previously dropped the JoinMode update. var builder = state.ToBuilder() .WithStage(SessionStage.SessionStarted) .WithStepIndex(state.StepIndex + 1) .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) .AddDeadline(classifierTick); - builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplySessionStarted( - builder.ScenarioProfile, signal); - var newState = builder.Build(); var transition = BuildTakenTransition( @@ -500,6 +500,43 @@ namespace AutopilotMonitor.DecisionCore.Engine return new DecisionStep(newState, transition, Array.Empty()); } + /// + /// V2 race-fix (10c8e0bf debrief, 2026-04-26) — record the registry-derived + /// enrollment facts (enrollmentType + isHybridJoin) on the + /// scenario profile. Stage is unchanged; this is a fact-only signal whose + /// reducer is intentionally stage-agnostic so it can land at any point in the + /// session without being swallowed by a Stage-Wache (the bug that motivated + /// this signal: the legacy + /// path lost the same payload when other signals had already advanced Stage + /// past ). + /// + /// Idempotency / monotonicity are owned by + /// — + /// the handler delegates the merge logic and only carries out the standard + /// step bookkeeping + transition. + /// + /// + private DecisionStep HandleEnrollmentFactsObservedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + builder.ScenarioProfile, signal); + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EnrollmentFactsObserved)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + /// /// PR4 (882fef64 debrief) — record the WHfB / Hello-for-Business policy fact on the /// engine state. Stage is NOT changed; this is a fact-only signal that downstream diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 075b571d..687e1ffc 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -95,6 +95,7 @@ namespace AutopilotMonitor.DecisionCore.Engine (DecisionSignalKind.AutopilotProfileRead, 1) => HandleAutopilotProfileReadV1(state, signal), (DecisionSignalKind.EspConfigDetected, 1) => HandleEspConfigDetectedV1(state, signal), (DecisionSignalKind.HelloPolicyDetected, 1) => HandleHelloPolicyDetectedV1(state, signal), + (DecisionSignalKind.EnrollmentFactsObserved, 1) => HandleEnrollmentFactsObservedV1(state, signal), // ----- Informational pass-through (DecisionEngine.Shared.cs) — single-rail §1.3 ----- (DecisionSignalKind.InformationalEvent, 1) => HandleInformationalEventV1(state, signal), diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs index 02d01d94..eebce6cb 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -35,6 +35,24 @@ namespace AutopilotMonitor.DecisionCore.Signals // gate enrollment_complete — completion stays orthogonal to Hello policy. HelloPolicyDetected, + // V2 race-fix (10c8e0bf debrief, 2026-04-26) — static enrollment facts read + // from the Autopilot policy registry (EnrollmentRegistryDetector). Carries + // { "enrollmentType": "v1|v2", "isHybridJoin": "true|false" }. + // + // Replaces the profile-seeding side of , which had + // a Stage-Wache that swallowed the update when the signal arrived after the + // first non-anchor signal had already moved Stage off SessionStarted (the + // V2-Tracker / Backend-register-session race). + // + // + // Reducer guarantees: stage-agnostic (no + // restriction), idempotent (re-posting the same facts is a no-op on the + // profile, only the EvidenceOrdinal advances), and monotonic (a later signal + // with the registry default-fallback values cannot regress a value that was + // already established as Known). + // + EnrollmentFactsObserved, + // --- Raw — WhiteGlove Part 2 (Post-Reboot User-Sign-In) --- UserAadSignInComplete, HelloResolvedPart2, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentJoinMode.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentJoinMode.cs index 50403397..22591926 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentJoinMode.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentJoinMode.cs @@ -3,8 +3,11 @@ namespace AutopilotMonitor.DecisionCore.State /// /// Directory-join method observed at session start. Codex follow-up #5 — /// dimension of . Derived from the - /// isHybridJoin payload on the SessionStarted signal - /// (EnrollmentRegistryDetector.DetectHybridJoin). + /// isHybridJoin payload on the + /// signal + /// (EnrollmentRegistryDetector.DetectHybridJoin). V2 race-fix + /// (10c8e0bf debrief, 2026-04-26): previously sourced from SessionStarted, + /// which had a Stage-Wache that swallowed the update on race conditions. /// /// This is NOT the same concept as the legacy AadJoinedWithUser fact, which /// tracked a late-AADJ user-presence signal for the WhiteGlove hard-excluder weight. diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs index ebe1914f..25b834c2 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs @@ -19,14 +19,32 @@ namespace AutopilotMonitor.DecisionCore.State /// public static class EnrollmentScenarioProfileUpdater { - // ================================================================== SessionStarted + // ================================================================== EnrollmentFactsObserved /// - /// Seed the profile from a signal. - /// Payload keys: (literal "v1" / "v2" - /// from the registry detector), . + /// Seed the profile from a + /// signal. Payload keys: (literal + /// "v1" / "v2" from EnrollmentRegistryDetector.DetectEnrollmentType), + /// (boolean from + /// EnrollmentRegistryDetector.DetectHybridJoin). + /// + /// V2 race-fix (10c8e0bf debrief, 2026-04-26) — replaces the profile-seeding side + /// of , which lived inside the Stage-anchored + /// HandleSessionStartedV1 handler and was therefore lost whenever the + /// SessionStarted signal arrived after the first non-anchor signal moved Stage + /// off SessionStarted. This handler is stage-agnostic on the reducer side, so + /// the race vanishes structurally. + /// + /// + /// Idempotency: a repeat signal with identical facts produces no profile + /// change (returns unchanged). Monotonicity: + /// once is non-, + /// a subsequent signal cannot overwrite it — protects against a flaky registry + /// re-read returning the default false after the real value was learned + /// (mirrors the set-once semantics already used by ). + /// /// - public static EnrollmentScenarioProfile ApplySessionStarted( + public static EnrollmentScenarioProfile ApplyEnrollmentFactsObserved( EnrollmentScenarioProfile current, DecisionSignal signal) { @@ -37,7 +55,6 @@ namespace AutopilotMonitor.DecisionCore.State var joinMode = current.JoinMode; var confidence = current.Confidence; var reason = current.Reason; - var evidenceOrdinal = current.EvidenceOrdinal; var changed = false; if (signal.Payload != null) @@ -50,17 +67,27 @@ namespace AutopilotMonitor.DecisionCore.State // the finer UserDriven / SelfDeploying / WhiteGlove classification comes // from later signals (AccountSetup phase, ImeUserSessionCompleted, // DeviceSetupProvisioningComplete, classifier verdicts). + // + // Set-once on the reason annotation: the v1 branch leaves Mode at Unknown, + // so without this guard a repeat post would re-trigger `changed=true` purely + // through the reason slot and break the idempotency contract documented on + // the public API. Mirroring the v2 branch's natural set-once (Mode promotion + // gate) keeps both branches structurally identical. + var newReason = string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase) + ? "enrollment_facts_observed:v2" + : $"enrollment_facts_observed:{rawType}"; + if (string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase)) { mode = EnrollmentMode.DevicePreparation; confidence = Max(confidence, ProfileConfidence.Medium); - reason = "session_started:v2"; + reason = newReason; changed = true; } - else + else if (!string.Equals(current.Reason, newReason, StringComparison.Ordinal)) { - // v1 (or any other): keep Mode=Unknown but record the reason. - reason = $"session_started:{rawType}"; + // v1 (or any other): keep Mode=Unknown but record the reason once. + reason = newReason; changed = true; } } @@ -263,8 +290,9 @@ namespace AutopilotMonitor.DecisionCore.State /// is a classifier-state update only /// (see feedback_aad_joined_late_not_completion). It does NOT touch /// — that comes from - /// SessionStarted.payload.isHybridJoin. The payload-carrying presence flag is - /// stored in ; + /// isHybridJoin + /// payload (V2 race-fix, 10c8e0bf debrief 2026-04-26). The payload-carrying + /// presence flag is stored in ; /// the profile only records the reason annotation. /// public static EnrollmentScenarioProfile ApplyAadUserJoinedLate( diff --git a/tests/fixtures/signal-kinds/enrollment-facts-observed-v1.json b/tests/fixtures/signal-kinds/enrollment-facts-observed-v1.json new file mode 100644 index 00000000..c7ba97e4 --- /dev/null +++ b/tests/fixtures/signal-kinds/enrollment-facts-observed-v1.json @@ -0,0 +1,11 @@ +# EnrollmentFactsObserved v1 — V2 race-fix (10c8e0bf debrief, 2026-04-26). +# Carries the registry-derived enrollment facts read by EnrollmentRegistryDetector +# (CloudAssignedDomainJoinMethod + CloudAssignedDeviceRegistration / CloudAssignedEspEnabled). +# Reducer handler HandleEnrollmentFactsObservedV1 is stage-agnostic: it does NOT +# mutate DecisionState.Stage and can land at any point in the session without being +# swallowed by a Stage-Wache. The handler delegates to +# EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved which is idempotent +# (re-posting the same facts produces no profile change) and monotonic (a later signal +# with the registry default-fallback values cannot regress a value already set to Known). +# Standard fixture sample = Classic AADJ enrollment (CloudAssignedDomainJoinMethod=0). +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EnrollmentFactsObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-26T11:42:17.4030000Z","SourceOrigin":"Program.RunAgent","Evidence":{"Kind":"Synthetic","Identifier":"enrollment_registry_facts_read","Summary":"Registry-derived enrollment facts read for scenario-profile seeding."},"Payload":{"enrollmentType":"v1","isHybridJoin":"false"}}