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:
Oliver Kieselbach
2026-05-04 09:37:39 +02:00
parent 11838e1719
commit ce97a583d0
10 changed files with 64 additions and 38 deletions
@@ -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);
}
// ============================================================================
@@ -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)
}