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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-27 09:19:34 +02:00
parent fbb338efcc
commit 579291272e
14 changed files with 370 additions and 43 deletions
@@ -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
{
/// <summary>
/// V2 race-fix (10c8e0bf debrief, 2026-04-26) — verifies that
/// <see cref="AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved"/>
/// emits a <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> signal carrying the
/// registry-derived enrollment facts so the reducer can seed
/// <see cref="State.EnrollmentScenarioProfile"/> via the stage-agnostic facts handler.
/// </summary>
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<string, string>)payload!);
Assert.Contains("isHybridJoin", (IDictionary<string, string>)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<string, string>? payload = null,
int kindSchemaVersion = 1,
object? typedPayload = null)
{
throw new System.InvalidOperationException("ingress unavailable");
}
}
}
}
@@ -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<string, string>)payload);
Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary<string, string>)payload);
Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary<string, string>)payload);
Assert.Equal("1.2.3.4", payload["agentVersion"]);
Assert.DoesNotContain("enrollmentType", (System.Collections.Generic.IDictionary<string, string>)payload);
Assert.DoesNotContain("isHybridJoin", (System.Collections.Generic.IDictionary<string, string>)payload);
}
[Fact]
@@ -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);
}
@@ -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<string, string>(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
}
}
/// <summary>
/// V2 race-fix (10c8e0bf debrief, 2026-04-26) — post a
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> signal carrying the
/// registry-derived enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>)
/// so the reducer can seed <see cref="State.EnrollmentScenarioProfile"/> via the
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler.
/// <para>
/// Posted immediately before <see cref="PostSessionStarted"/> 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.
/// </para>
/// <para>
/// AwaitEnrollment-mode is transparently covered: the bootstrap layer
/// (<c>AgentBootstrap</c>) 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.
/// </para>
/// </summary>
public static void PostEnrollmentFactsObserved(
ISignalIngressSink ingressSink,
AgentLogger logger)
{
try
{
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
var payload = new Dictionary<string, string>(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}");
}
}
/// <summary>
/// V2 parity — post <see cref="DecisionSignalKind.AdminPreemptionDetected"/> when the
/// register-session response carried an <c>AdminAction</c>. The reducer transitions
@@ -1 +1 @@
382
390
@@ -1 +1 @@
1186
1187
@@ -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<string, string>
{
@@ -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<string, string>
{
@@ -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<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
});
var second = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 7,
payload: new Dictionary<string, string>
{
[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<string, string>
{
[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<string, string>
{
[SignalPayloadKeys.IsHybridJoin] = "false",
});
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, flapped);
Assert.Equal(EnrollmentJoinMode.HybridAzureAdJoin, p2.JoinMode);
}
// ================================================================== EspConfigDetected
[Fact]
@@ -320,8 +320,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// terminal event is emitted. Codex follow-up #5: the user-presence flag lives in
/// <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/> (NOT
/// <see cref="EnrollmentScenarioProfile.JoinMode"/> — that is strictly the
/// <c>SessionStarted.payload.isHybridJoin</c> value), and the profile's
/// <see cref="EnrollmentScenarioProfile.Reason"/> is annotated without touching Mode.
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> <c>isHybridJoin</c>
/// payload), and the profile's <see cref="EnrollmentScenarioProfile.Reason"/> is
/// annotated without touching Mode.
/// </para>
/// </summary>
private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal)
@@ -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<DecisionEffect>());
}
/// <summary>
/// V2 race-fix (10c8e0bf debrief, 2026-04-26) — record the registry-derived
/// enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>) 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 <see cref="DecisionSignalKind.SessionStarted"/>
/// path lost the same payload when other signals had already advanced Stage
/// past <see cref="SessionStage.SessionStarted"/>).
/// <para>
/// Idempotency / monotonicity are owned by
/// <see cref="EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved"/> —
/// the handler delegates the merge logic and only carries out the standard
/// step bookkeeping + transition.
/// </para>
/// </summary>
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<DecisionEffect>());
}
/// <summary>
/// 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
@@ -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),
@@ -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" }.
// <para>
// Replaces the profile-seeding side of <see cref="SessionStarted"/>, 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).
// </para>
// <para>
// Reducer guarantees: stage-agnostic (no <see cref="DecisionState.Stage"/>
// 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).
// </para>
EnrollmentFactsObserved,
// --- Raw — WhiteGlove Part 2 (Post-Reboot User-Sign-In) ---
UserAadSignInComplete,
HelloResolvedPart2,
@@ -3,8 +3,11 @@ 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>).
/// <c>isHybridJoin</c> payload on the
/// <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/> signal
/// (<c>EnrollmentRegistryDetector.DetectHybridJoin</c>). V2 race-fix
/// (10c8e0bf debrief, 2026-04-26): previously sourced from <c>SessionStarted</c>,
/// which had a Stage-Wache that swallowed the update on race conditions.
/// <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.
@@ -19,14 +19,32 @@ namespace AutopilotMonitor.DecisionCore.State
/// </summary>
public static class EnrollmentScenarioProfileUpdater
{
// ================================================================== SessionStarted
// ================================================================== EnrollmentFactsObserved
/// <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"/>.
/// Seed the profile from a <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>
/// signal. Payload keys: <see cref="SignalPayloadKeys.EnrollmentType"/> (literal
/// "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType</c>),
/// <see cref="SignalPayloadKeys.IsHybridJoin"/> (boolean from
/// <c>EnrollmentRegistryDetector.DetectHybridJoin</c>).
/// <para>
/// V2 race-fix (10c8e0bf debrief, 2026-04-26) — replaces the profile-seeding side
/// of <see cref="ApplySessionStarted"/>, which lived inside the Stage-anchored
/// <c>HandleSessionStartedV1</c> 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.
/// </para>
/// <para>
/// <b>Idempotency</b>: a repeat signal with identical facts produces no profile
/// change (returns <paramref name="current"/> unchanged). <b>Monotonicity</b>:
/// once <see cref="EnrollmentScenarioProfile.JoinMode"/> is non-<see cref="EnrollmentJoinMode.Unknown"/>,
/// a subsequent signal cannot overwrite it — protects against a flaky registry
/// re-read returning the default <c>false</c> after the real value was learned
/// (mirrors the set-once semantics already used by <see cref="ApplyEspConfigDetected"/>).
/// </para>
/// </summary>
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
/// <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"/>;
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> <c>isHybridJoin</c>
/// payload (V2 race-fix, 10c8e0bf debrief 2026-04-26). The payload-carrying
/// presence flag is stored in <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/>;
/// the profile only records the reason annotation.
/// </summary>
public static EnrollmentScenarioProfile ApplyAadUserJoinedLate(
@@ -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"}}