mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(v2): propagate AadJoinedWithUser + rename aad_user_joined_late→_observed
AadJoinWatcherAdapter never wrote SignalPayloadKeys.AadJoinedWithUser into the
decision-signal payload, so HandleAadUserJoinedLateV1 always read withUser=false.
Every Classic AAD-Join session ended up with aad_user_joined_device_only in the
audit trail and a wrongly-flipped WhiteGloveSealingClassifier weight. The watcher
only fires AadUserJoined for non-placeholder accounts (placeholders go via the
separate PlaceholderUserDetected event), so the flag is unconditionally true at
emit time.
Cosmetic: rename the wire EventType from aad_user_joined_late to
aad_user_joined_observed. The "late" qualifier reflected the agent's observation
perspective ("I missed the live event"), which holds for 100% of Classic sessions
and reads as misleading in the timeline (the event lands seconds after agent
start, before aad_join_status). FailureSnapshotBuilder accepts both strings — V1
agents and historical data still carry the legacy name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-5
@@ -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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+1
-1
@@ -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 },
|
||||
|
||||
@@ -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<string, string>(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,
|
||||
|
||||
@@ -1 +1 @@
|
||||
555
|
||||
559
|
||||
|
||||
@@ -1 +1 @@
|
||||
1241
|
||||
1242
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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<List<string>>()!;
|
||||
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<List<string>>()!;
|
||||
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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user