diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs index dd25af7b..1068c134 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs @@ -51,13 +51,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Equal(EvidenceKind.Derived, decisionSignal.Evidence.Kind); Assert.Equal("contoso.com", decisionSignal.Payload!["userDomain"]); Assert.Equal("true", decisionSignal.Payload["hasThumbprint"]); + // Session 67963703 debrief 2026-05-04: AadJoinWatcher only fires AadUserJoined + // for non-placeholder accounts (placeholder goes via PlaceholderUserDetected). + // The withUser flag must therefore always be true here, so HandleAadUserJoinedLateV1 + // records aad_user_joined_with_user (not _device_only) in the audit trail. + Assert.Equal("true", decisionSignal.Payload[SignalPayloadKeys.AadJoinedWithUser]); // PII guard: full email MUST NOT appear in either rail's payload. Assert.DoesNotContain("alice@", string.Concat(decisionSignal.Payload.Values)); } [Fact] - public void TriggerFromTest_also_emits_aad_user_joined_late_informational_event() + public void TriggerFromTest_also_emits_aad_user_joined_observed_informational_event() { using var f = new Fixture(); using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); @@ -72,7 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et == SharedEventTypes.AadUserJoinedLate); + && et == SharedEventTypes.AadUserJoinedObserved); Assert.Equal("AadJoinWatcher", info.Payload![SignalPayloadKeys.Source]); Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]); Assert.Equal("Info", info.Payload[SignalPayloadKeys.Severity]); @@ -144,7 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters p => p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et == SharedEventTypes.AadUserJoinedLate); + && et == SharedEventTypes.AadUserJoinedObserved); Assert.Equal(2, f.Ingress.Posted.Count); } @@ -219,7 +224,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters // 3 posts total after Codex review 2026-05-01 dual-emission: // - placeholder informational event // - real-user decision signal (AadUserJoinedLate) - // - real-user informational event (aad_user_joined_late) + // - real-user informational event (aad_user_joined_observed) Assert.Equal(3, f.Ingress.Posted.Count); Assert.Single(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.InformationalEvent @@ -231,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et2) - && et2 == SharedEventTypes.AadUserJoinedLate); + && et2 == SharedEventTypes.AadUserJoinedObserved); } // ============================================================================ diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs index 29253a4b..f018057c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterDecisionStateEnrichmentTests.cs @@ -56,7 +56,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events new object[] { SharedConstants.EventTypes.NetworkStateChange }, new object[] { SharedConstants.EventTypes.DesktopArrived }, new object[] { SharedConstants.EventTypes.AadPlaceholderUserDetected }, - new object[] { SharedConstants.EventTypes.AadUserJoinedLate }, + new object[] { SharedConstants.EventTypes.AadUserJoinedObserved }, new object[] { SharedConstants.EventTypes.UserAadSignInComplete }, new object[] { SharedConstants.EventTypes.HybridLoginPending }, new object[] { SharedConstants.EventTypes.AgentShuttingDown }, diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs index 6f95180f..b2cdcb64 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs @@ -82,6 +82,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters // for audit trail; full email lives only in local agent logs if at all. ["userDomain"] = ExtractDomain(userEmail), ["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true", + // The AadJoinWatcher fires AadUserJoined ONLY when isPlaceholderUser==false + // (placeholder accounts go via the separate PlaceholderUserDetected event). + // So when this signal posts, by definition the join carries a real user. + // Without this key, HandleAadUserJoinedLateV1 reads withUser=false and the + // enrollment_complete audit trail records aad_user_joined_device_only — which + // also flips WhiteGloveSealingClassifier weights the wrong way. + [SignalPayloadKeys.AadJoinedWithUser] = "true", }; _ingress.Post( @@ -93,7 +100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters identifier: "aad-join-watcher-v1", summary: _part2Mode ? "Post-reboot AAD user sign-in detected (JoinInfo registry key)" - : "Late AAD user join detected (JoinInfo registry key)", + : "AAD user join observed (JoinInfo registry key)", derivationInputs: new Dictionary(StringComparer.Ordinal) { ["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo", @@ -108,10 +115,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters // aadJoinState=real_user. Same PII-safe payload shape as the decision signal. var infoEventType = _part2Mode ? SharedConstants.EventTypes.UserAadSignInComplete - : SharedConstants.EventTypes.AadUserJoinedLate; + : SharedConstants.EventTypes.AadUserJoinedObserved; var infoMessage = _part2Mode ? $"Post-reboot AAD user sign-in detected (domain={payload["userDomain"]})" - : $"Late AAD user join detected (domain={payload["userDomain"]})"; + : $"AAD user join observed (domain={payload["userDomain"]})"; _post.Emit( eventType: infoEventType, source: SourceLabel, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 3749383d..30ce1158 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -555 +559 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 183f97e6..bd9b5070 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1241 +1242 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/LifecycleAnchorEventTypesTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/LifecycleAnchorEventTypesTests.cs index 8fffb7d8..f24f032c 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/LifecycleAnchorEventTypesTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/LifecycleAnchorEventTypesTests.cs @@ -20,7 +20,7 @@ namespace AutopilotMonitor.DecisionCore.Tests [InlineData(nameof(SharedConstants.EventTypes.NetworkStateChange))] [InlineData(nameof(SharedConstants.EventTypes.DesktopArrived))] [InlineData(nameof(SharedConstants.EventTypes.AadPlaceholderUserDetected))] - [InlineData(nameof(SharedConstants.EventTypes.AadUserJoinedLate))] + [InlineData(nameof(SharedConstants.EventTypes.AadUserJoinedObserved))] [InlineData(nameof(SharedConstants.EventTypes.UserAadSignInComplete))] [InlineData(nameof(SharedConstants.EventTypes.HybridLoginPending))] [InlineData(nameof(SharedConstants.EventTypes.AgentShuttingDown))] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs index c3be7ab5..978bcace 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FailureSnapshotBuilderTests.cs @@ -112,36 +112,43 @@ public class FailureSnapshotBuilderTests Assert.Equal("placeholder", (string?)obj["aadJoinState"]); } - [Fact] - public void Build_classifies_real_user_state_from_aad_user_joined_late() + [Theory] + // V2 since 2026-05-04 emits the renamed event... + [InlineData("aad_user_joined_observed")] + // ...but historical data + V1 agents still emit the legacy name. Snapshot must classify both. + [InlineData("aad_user_joined_late")] + public void Build_classifies_real_user_state_from_real_user_join_event(string eventType) { var json = FailureSnapshotBuilder.Build(new[] { - Event("aad_user_joined_late", Now.AddHours(-3)), + Event(eventType, Now.AddHours(-3)), }, Now); var obj = JObject.Parse(json!); Assert.Equal("real_user", (string?)obj["aadJoinState"]); } - [Fact] - public void Build_aad_user_join_slot_is_satisfied_by_part_1_event() + [Theory] + [InlineData("aad_user_joined_observed")] // V2 canonical since 2026-05-04 + [InlineData("aad_user_joined_late")] // V1 + historical data + public void Build_aad_user_join_slot_is_satisfied_by_part_1_event(string eventType) { // Codex review 2026-05-01 (Finding 1): the dual-emitted informational event // is what makes this work. Before the fix, AadJoinWatcherAdapter only posted // a DecisionSignal — HandleAadUserJoinedLateV1 emits no timeline effect, so - // the Events table never held an aad_user_joined_late row. The snapshot then - // always marked it missing AND classified aadJoinState as placeholder/unknown - // even on healthy real-user joins. + // the Events table never held a real-user-join row. The snapshot then always + // marked it missing AND classified aadJoinState as placeholder/unknown even + // on healthy real-user joins. Both legacy and renamed wire strings must work. var json = FailureSnapshotBuilder.Build(new[] { - Event("aad_user_joined_late", Now.AddHours(-3)), + Event(eventType, Now.AddHours(-3)), }, Now); var obj = JObject.Parse(json!); var missing = obj["missingSignals"]!.ToObject>()!; - Assert.DoesNotContain("aad_user_join", missing); // slot satisfied - Assert.DoesNotContain("aad_user_joined_late", missing); // literal must never appear + Assert.DoesNotContain("aad_user_join", missing); // slot satisfied + Assert.DoesNotContain("aad_user_joined_observed", missing); // literal must never appear + Assert.DoesNotContain("aad_user_joined_late", missing); // legacy literal either } [Fact] @@ -162,7 +169,8 @@ public class FailureSnapshotBuilderTests var missing = obj["missingSignals"]!.ToObject>()!; Assert.DoesNotContain("aad_user_join", missing); // slot satisfied - Assert.DoesNotContain("aad_user_joined_late", missing); // literal must never appear + Assert.DoesNotContain("aad_user_joined_observed", missing); // literal must never appear + Assert.DoesNotContain("aad_user_joined_late", missing); // legacy literal either Assert.DoesNotContain("user_aad_signin_complete", missing); // ditto } @@ -276,6 +284,7 @@ public class FailureSnapshotBuilderTests // Slot label is the conceptual name; the literal Part 1 / Part 2 event-type // names must NOT appear directly in missingSignals (that would double-count). + Assert.DoesNotContain("aad_user_joined_observed", missing); Assert.DoesNotContain("aad_user_joined_late", missing); Assert.DoesNotContain("user_aad_signin_complete", missing); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs index 848546f8..6602b545 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs @@ -30,8 +30,9 @@ namespace AutopilotMonitor.Functions.Services // Missing entries are surfaced in the snapshot under "missingSignals". Some entries // are conceptual slots that admit multiple concrete event types (see special-case // branches in Build): "hello_terminal" is satisfied by any Hello terminal event, - // and "aad_user_join" by either the Part 1 (aad_user_joined_late) or Part 2 - // (user_aad_signin_complete) real-user event. + // and "aad_user_join" by either the Part 1 (aad_user_joined_observed — was + // aad_user_joined_late before the 2026-05-04 rename, still emitted by V1 agents and + // present in historical data) or Part 2 (user_aad_signin_complete) real-user event. private static readonly string[] CanonicalCompletionSignals = { "esp_phase_changed", @@ -39,7 +40,7 @@ namespace AutopilotMonitor.Functions.Services "desktop_arrived", "hello_policy_detected", "hello_provisioning_completed", // OR _failed / _blocked / _skipped — emitted as "hello_terminal" slot - "aad_user_joined_late", // OR user_aad_signin_complete — emitted as "aad_user_join" slot + "aad_user_joined_observed", // OR aad_user_joined_late (legacy) / user_aad_signin_complete — "aad_user_join" slot "enrollment_complete", }; @@ -93,7 +94,8 @@ namespace AutopilotMonitor.Functions.Services // ---- AAD join state — placeholder vs real user vs unknown ------------------ // Sources (newest wins): // - aad_placeholder_user_detected → placeholder - // - aad_user_joined_late → real_user (Part 1 path) + // - aad_user_joined_observed → real_user (Part 1 path, V2 since 2026-05-04) + // - aad_user_joined_late → real_user (Part 1 path, V1 + historical data) // - user_aad_signin_complete → real_user (Part 2 path; future) // - aad_join_status → fallback heuristic via isFooUser flag string aadJoinState = "unknown"; @@ -106,7 +108,8 @@ namespace AutopilotMonitor.Functions.Services aadJoinState = "placeholder"; aadJoinStateAt = evt.Timestamp; } - else if (string.Equals(et, "aad_user_joined_late", StringComparison.OrdinalIgnoreCase) + else if (string.Equals(et, "aad_user_joined_observed", StringComparison.OrdinalIgnoreCase) + || string.Equals(et, "aad_user_joined_late", StringComparison.OrdinalIgnoreCase) || string.Equals(et, "user_aad_signin_complete", StringComparison.OrdinalIgnoreCase)) { aadJoinState = "real_user"; @@ -179,15 +182,17 @@ namespace AutopilotMonitor.Functions.Services missing.Add("hello_terminal"); } } - else if (sig == "aad_user_joined_late") + else if (sig == "aad_user_joined_observed") { // Codex review 2026-05-01: Part 1 (Classic / WhiteGlove) emits - // aad_user_joined_late; Part 2 (post-reboot user sign-in) emits - // user_aad_signin_complete. Both satisfy the same conceptual slot. - // Without this branch, every healthy Part-2 session would falsely - // list aad_user_joined_late as missing even though aadJoinState - // is correctly classified as real_user above. - if (!seenTypes.Contains("aad_user_joined_late") + // aad_user_joined_observed (V2 since 2026-05-04, was aad_user_joined_late + // in V1 + historical data); Part 2 (post-reboot user sign-in) emits + // user_aad_signin_complete. Any of the three satisfies the same conceptual + // slot — without this branch, every healthy Part-2 session would falsely + // list aad_user_joined_observed as missing even though aadJoinState is + // correctly classified as real_user above. + if (!seenTypes.Contains("aad_user_joined_observed") + && !seenTypes.Contains("aad_user_joined_late") && !seenTypes.Contains("user_aad_signin_complete")) { missing.Add("aad_user_join"); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/LifecycleAnchorEventTypes.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/LifecycleAnchorEventTypes.cs index c5898e00..f13cb195 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/LifecycleAnchorEventTypes.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/LifecycleAnchorEventTypes.cs @@ -32,7 +32,7 @@ namespace AutopilotMonitor.DecisionCore.Engine SharedConstants.EventTypes.NetworkStateChange, // network_state_change SharedConstants.EventTypes.DesktopArrived, // desktop_arrived SharedConstants.EventTypes.AadPlaceholderUserDetected, // aad_placeholder_user_detected - SharedConstants.EventTypes.AadUserJoinedLate, // aad_user_joined_late + SharedConstants.EventTypes.AadUserJoinedObserved, // aad_user_joined_observed SharedConstants.EventTypes.UserAadSignInComplete, // user_aad_signin_complete SharedConstants.EventTypes.HybridLoginPending, // hybrid_login_pending SharedConstants.EventTypes.AgentShuttingDown, // agent_shutting_down diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 488d3fac..56470310 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -247,7 +247,7 @@ namespace AutopilotMonitor.Shared // emits no timeline effect), so the AadJoinWatcherAdapter dual-emits them as // informational events too. Without this the FailureSnapshotBuilder cannot tell that // the real AAD user was ever observed (Codex review 2026-05-01). - public const string AadUserJoinedLate = "aad_user_joined_late"; // Real user replaces placeholder mid-session (Part 1 path) + public const string AadUserJoinedObserved = "aad_user_joined_observed"; // Real user observed in JoinInfo (Part 1 path) — synchronous startup-read OR mid-session watcher fire public const string UserAadSignInComplete = "user_aad_signin_complete"; // Real user signs in post-reboot (Part 2 path) }