mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+95
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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
|
||||
|
||||
+78
-12
@@ -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.
|
||||
|
||||
+40
-12
@@ -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"}}
|
||||
Reference in New Issue
Block a user