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"}}