Born from /session-debug analysis of f148976f: a customer platform script
(c3e0124c) hung for the full ~30-min IME script-execution timeout, blocking
IME's serial platform-script queue, so the Autopilot-Monitor bootstrap +
agent only started ~34 min after boot (right as ESP hit its own 30-min
SyncFailureTimeout). The agent observed only the already-decided end-state
and lived ~30s — a session that looks like a normal multi-minute failure but
had near-zero coverage of the actual failure window.
Two detectors classify this automatically:
- agent_late_start (Warning, EnrollmentTerminationHandler): fires when the
agent started >=10 min after boot AND lived <=5 min before a terminal
outcome. Device boot via GetTickCount64 P/Invoke (net48 has no
Environment.TickCount64), monotonic / clock-jump-resistant. Shared gate in
Termination/ObservationCoverage.cs (DRY with the builder). final-status.json
gains bootToAgentStartSeconds + lowObservationCoverage (FinalStatusBuilder
stays pure via an optional deviceBootUtc param).
- script_timeout_suspected (Warning, ImeLogTrackerAdapter): a platform script
that ran >=25 min AND ended Failed -> one-shot per policyId, naming it as the
likely enrollment-pipeline blocker. Works on replay (StartedAtUtc from the
CMTrace source timestamp), so it fires even when the script ran before the
agent launched.
Plus per-script run duration for all platform scripts: new
ScriptExecutionState.StartedAtUtc -> durationSeconds on every
script_completed/script_failed -> Web ScriptExecutions renders "Duration"
(amber past 10 min) so slow/inefficient scripts surface generally.
Scope: platform scripts only (remediation/health-script duration deferred --
the multi-source HS reducer makes clean attribution fragile). No ConfigVersion
bump, no backend code (ingest is event-agnostic). New event types wired through
the mandatory dual-sync: Constants.EventTypes -> MCP resource-catalog ->
Web eventTypes (drift tests enforce).
Tests: V2.Core 1536 (+88), MCP drift 6, MCP/Web tsc 0, Web 84. The f148976f
case is reproduced in unit tests (ObservationCoverage 34min/30s; adapter
c3e0124c 30min/Failed). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two V2 IME fixes, verified against client logs of sessions fc48c71a / 6b4993e5.
Bug 1 — on an ESP terminal failure of the DeviceSetup Apps subcategory the only
device app was stuck mid-install (RealmJoin stuck Downloading 0%) and IME logged
no per-app error, so app_tracking_summary showed 0 failed and the failure was
visible only via the opaque "Apps (Error)" registry string. New
esp_apps_failure_correlation event names the in-flight (IsActive: InProgress/
Downloading/Installing) device app(s) as the likely cause WITHOUT mutating state
(no fabricated app_install_failed) and only on the Apps subcategory.
Bug 2 — AgentExecutor.exe hosts both platform and proactive-remediation scripts
interleaved in the shared AgentExecutor.log; the policyId-less PS-AGENT-EXITCODE /
PS-AGENT-OUTPUT lines from a remediation invocation bled into the last-started
platform script via the sticky pointer (c3e0124c emitted result=Failed but with
exit 0 + the Teams remediation's "[Compliant] No Classic Teams found" stdout).
New PS-AGENT-INVOCATION pattern (resetPlatformScriptContext) clears the platform
line-capture pointer at every "ExecutorLog AgentExecutor gets invoked" boundary,
so a remediation invocation cannot capture into a platform slot; the platform
result stays keyed by policyId via PS-SCRIPT-RESULT.
Agent-only, V2, inert until redeploy; no ConfigVersion bump. New event type added
to the MCP resource catalog (dual-sync). Green: V2.Core 1522, DecisionCore 400,
Backend BuiltInRules 8, MCP vitest 252.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.
Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
(incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
(writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole
MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.
Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.
Tests: backend 2369, MCP 252, web 469 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V2 emitted script_completed for platform scripts ONLY on the IME PS-SCRIPT-RESULT
line (IntuneManagementExtension.log). The AgentExecutor.log exit code + stdout merely
filled _pendingPlatformScripts and never emitted on their own. IME logs its policy
result after its batch-send to the service, so scripts that finish shortly before the
(often short) Autopilot enrollment ends were silently dropped — the buffer is neither
persisted nor flushed on dispose. Field-confirmed on session d1da052d: 4 platform
scripts ran, only the long-running one (whose IME result was logged in time) reached
the backend.
Add a deadline-based fallback in the live polling loop: a pending platform script with
a known exit code but no PS-SCRIPT-RESULT within a 15s grace is emitted from the exit
code (0 -> Success, else Failed), tagged resultSource=agentexecutor_fallback so the
provenance is honest. A force pass runs on shutdown. Dedup via _platformScriptResultEmitted
prevents a double emit when the authoritative result arrives after the fallback; a fresh
scriptStarted clears the marker so IME re-evaluations/retries of the same policy emit again.
The fallback event binds to the script's own exit timestamp (not the last-matched line)
and omits the stale patternId.
Reuses script_completed/script_failed (no new event type, no ConfigVersion bump, no MCP
catalog change). V2-only; legacy V1 agent unchanged. 1516/1516 V2.Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Surface WMI watcher arm failures: new ConsoleBypassWatcher.WatcherArmFailed
event -> host emits collector_degraded (reason=watcher_arm_failed) via
CollectorDegradationReporter, so the backend can tell "no console seen" from
"live detector never started" instead of both looking like a clean run.
- Close a startup blind gap: arm the live Win32_ProcessStartTrace FIRST, then
run the startup snapshot. A cmd spawned between snapshot and arm was previously
missed; the _seen set dedups the now-possible overlap.
- Fix command-line classification for combined cmd switches: strip the executable
token, then match /c|/k anywhere in the arguments (was anchored to whitespace),
so e.g. `cmd /d/q/c exit 9` is correctly scripted (was false high-confidence).
- Docs: replace stale parent-based wording (AgentConfigResponse, the Web analyzer
toggle help text, the prefetch coverageNote) with the interactive-session model.
Tests: combined-switch + StripExecutable cases added. V2.Core 1505/1505.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field session f240a555 (Win11 24H2) disproved the parent==winlogon assumption:
the Shift+F10 cmd's parent (pid 9016) was a short-lived non-winlogon launcher
that exited within ms ("parent resolve failed ... is not running"), and the cmd
ran as defaultuser0, not SYSTEM. The conservative "can't confirm winlogon -> don't
flag" path therefore dropped a real Shift+F10 console.
ConsoleBypassWatcher rewritten to be parent-free:
- Gate race-free on SessionID != 0 (interactive session) from the trace — IME/
Intune install `cmd /c` run as SYSTEM in session 0 and are dropped without a probe.
- Confirm on the cmd's OWN command line: bare (no /c|/k) = interactive shell ->
confidence=high; a scripted `cmd /c` is ignored.
- Instant-close race (command line unreadable before the probe) -> still surfaced
as confidence=low (interactive_session_unclassified) rather than missed.
- Event data now: sessionId, owner, commandLine, confidence, classification,
parentProcessId (dropped parentProcessName/parentMatchesWinlogon).
Lifecycle gate: ConsoleBypassHost.StopForDesktopArrival() wired into the
DesktopArrival onRealUserOwnerObserved callback — once the real-user desktop
arrives (excludes SYSTEM + defaultuser0) Shift+F10 is no longer possible, so the
watcher stops and cannot flag ordinary post-enrollment user consoles. AccountSetup
phase alone is too early (the portal shows it before the real desktop, while
Shift+F10 still works), so desktop arrival is the binding stop signal.
Tests rewritten: Classify + HasScriptArgument (pure), high/low-confidence,
session-0 ignore, scripted ignore, dedup, owner enrichment. V2.Core 1499/1499.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Detects a SYSTEM console opened during enrollment — the classic Shift+F10
OOBE bypass (winlogon.exe spawning cmd.exe as SYSTEM). Two complementary
Warning signals, one per-tenant toggle EnableConsoleBypassDetection
(default ON / opt-out), ConfigVersion 31 -> 32:
- oobe_console_spawned (live): ConsoleBypassWatcher + ConsoleBypassHost.
WMI Win32_ProcessStartTrace on cmd.exe + startup probe; flags only a
cmd.exe whose parent is winlogon.exe. PID dedup happens ONLY after a
confirmed winlogon match, so an ignored cmd cannot suppress a later real
one via PID reuse.
- console_prefetch_detected (startup forensic): ConsolePrefetchScanner
(IAgentAnalyzer). A CMD.EXE-*.pf prefetch artifact whose last-run is
after boot, covering the pre-agent OOBE window the live watcher misses.
stat-only; corroborating artifacts sorted for a stable gate fingerprint.
Both events carry coverageComplete:false + an honest coverageNote (and the
prefetch event attribution:"coarse") — detection is best-effort, not gapless,
and only reports, never blocks.
Plumbing: Constants (2 event types) + MCP resource-catalog.ts + Web
eventTypes.ts; AnalyzerConfiguration/TenantConfiguration + GetAgentConfig
(default true) + TableConfigRepository; DefaultComponentFactory host gate +
AgentAnalyzerManager registration; full Web opt-out toggle. Tests: watcher
classification (9) + prefetch scanner (7) + analyzer-manager on/off.
Also fix(web): unrestricted-mode toggle persisted the stale pre-toggle value
because onSave() read async React state. saveConfiguration now takes an
explicit unrestrictedMode override that the toggle passes through, so the
saved value matches the UI state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SELECT ProcessId omitted the key property Handle, so WMI could not construct
__PATH and InvokeMethod("GetOwner") throws under classic WMI — the entire WMI
fallback in ProcessOwnerLookup.ViaWmi would have been silently dead
(throw → catch → wmiErrored=true → null), a regression vs the old SELECT *.
SELECT Handle, ProcessId restores method-callability. Not caught by tests: the
WMI path has no direct coverage (DAD tests inject the ProcessOwnerResolver seam).
V2.Core 1467/1467 green, build 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four byte-identical `SELECT * FROM Win32_Process … GetOwner` blocks resolved the
explorer.exe owner independently. Consolidate into a single WTS-primary helper
(Monitoring/Interop/ProcessOwnerLookup) so DesktopArrivalDetector, UserProfileResolver,
LocalAdminAnalyzer and UserSessionProcessLauncher share one implementation.
- WTS-primary (WTSQuerySessionInformation), WMI fallback — the other 3 sites inherit
the 4d5a0b78 robustness win (device where GetOwner failed on every poll).
- WMI fallback projects SELECT ProcessId (GetOwner binds via __PATH) instead of SELECT *.
- ViaWmi(pid, out bool wmiErrored) preserves DAD's _wmiErrorCountSinceStart liveness
counter exactly (vanished-PID zero-row case is not an error); DAD keeps its counters
and SessionOwnerResolver/ProcessOwnerResolver test seams.
- Constraint documented: WTS resolves the session user, equal to the process owner only
for explorer.exe; all 4 sites are explorer-only + skip SessionId==0.
V2.Core 1467/1467 green, build 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the device awake (system + display via SetThreadExecutionState) during the
User-ESP / AccountSetup phase so idle standby/sleep can't stall app installs or
account provisioning. Reboots are unaffected; the OS auto-clears the hold on
process exit, so it can never leak.
- UserEspKeepAwakeHost observes the signal rail: engage on AccountSetup phase,
release on AccountSetupProvisioningComplete / host stop / safety cap.
- Safety cap is derived from the tenant's Intune ESP SyncFailureTimeout (+30 min
margin), read at engage time; falls back to 90 min when unavailable. Resolved
cap + source are reported on keep_awake_engaged for fleet observability.
- KeepAwakeController owns the hold on a dedicated thread (ES_CONTINUOUS bound to
the thread's lifetime). Dispose guard on the engage-ready timeout path.
- Per-tenant portal toggle KeepAwakeDuringUserEsp (ConfigVersion 30 -> 31) with
full config plumbing (Tenant/Analyzer config, table storage, agent delivery)
and Web settings UI.
- New events keep_awake_engaged / keep_awake_released across Constants, the MCP
resource catalog, and the Web gather-rule on_event autocomplete.
- Tests: KeepAwakeController late-confirm timeout edge, host engage/release/cap,
and pure cap-resolution policy (V2.Core 1467 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OfficeInstallDetector fired a false office_install_failed on devices with
pre-installed OEM/consumer inbox Office (e.g. O365HomePremRetail +
OneNoteFreeRetail) running a background C2R CLIENTUPDATE/maintenance scenario:
the already-resident Office was mis-read as a failed Microsoft 365 Apps install,
with a benign small-integer scenario value ("2") treated as a failure code.
Field telemetry (202 office_install_started events) showed 200/202 real installs
start with streamingFinished=false (a live download) and only 2 already-resident
— so the clean discriminator is "already on disk", not the scenario name. A
"require INSTALL scenario to start" gate was rejected: 47% of real starts fire
via the early DO-CDN job before C2R writes its registry.
Changes:
- New office_preinstalled_detected (Info): emitted when the core Office binaries
are already on disk at the first signal AND the product set is consumer/OEM
inbox (OfficeProductClassifier). Anchored on the filesystem, NOT the flaky
StreamingFinished value (absent on some machines). Enterprise Office already on
disk keeps the started->completed path (CSP/Win32-wrapper case).
- Does NOT latch terminal: an enrollment often uninstalls the inbox Office and
installs a fresh one, so the detector stays armed (emit-once guard +
StatePreinstalled persisted for cross-reboot dedup, ResumePreinstalled set
before the process watcher's startup probe).
- Error attribution scoped to the INSTALL scenario only (+ errorSource); a
CLIENTUPDATE error never fails an install.
- Registry idle-start gated on InstallScenarioPresent (not any scenario).
- IsNonZeroNumericCode rejects implausibly small positive decimals (<1000).
- DO bytes are seeded only once a real lifecycle opens, so a preinstalled/
CLIENTUPDATE download cannot leak into a later install's doSummary.
- Web InstallProgress renders a sky "Pre-installed" info row; MCP + web event
catalogs updated.
V2.Core 1454/1454, web tsc clean, MCP 240 passed. Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
disk_free_gb was previously only visible inside the Debug performance_snapshot
payload (filtered out of timelines), so a disk-starved enrollment carried no
actionable signal. PerformanceCollector now emits a dedicated disk_space_low
Warning (ImmediateUpload) when the system drive crosses below 2 GB free.
State-change-only (no heartbeat): strict <2 GB fire, re-arm only above 3 GB
(1 GB hysteresis) so a single enrollment that expands then cleans its temp
cache can warn again without flapping. Decision lives in pure, testable
PerformanceCollector.EvaluateDiskLowTransition(double, ref bool).
- new Constants.EventTypes.DiskSpaceLow = "disk_space_low"
- 'disk_space_low' added to MCP resource-catalog health_events (drift sync)
- PerformanceCollectorDiskLowTests (7 cases); guard + drift tests green
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The office_install_completed doSummary paired aggregateFileSize next to
totalBytesDownloaded with no context, making the (expected) gap look like a
bug: the completed event fires at core-binary completion while background
streaming of the remaining declared content is still in flight, so the peak
DO sample shows downloaded < declared. aggregateFileSize is the sum of the
DO jobs' declared target sizes at the peak instant, not a stable payload total.
Mirror downloadPercent (from OfficeDoSample.DownloadPercent) and
streamingFinished into the summary so the gap is self-explanatory. XML-doc
now states why aggregate exceeds downloaded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RealmJoinWatcher was always-on, gated only by a compile-time const. Most
tenants do not deploy via RealmJoin, so the watcher and its 60-min completion
gate were pure noise. Add a per-tenant portal toggle (Agent Analyzers ->
RealmJoin Watcher), default off, delivered to the agent via remote config.
Mirrors the EnableIntegrityBypassAnalyzer flow end-to-end, with one divergence:
RealmJoin is a collector host (not an IAgentAnalyzer), so the agent-side gate
lives in DefaultComponentFactory.CreateCollectorHosts, not AgentAnalyzerManager.
When disabled the host is not created; the existing null-guard in the
DesktopArrival callback handles the absent HKU arming. The compile-time const
RealmJoinTrackingEnabled is kept as a build-time master kill-switch
(effective enable = remote flag AND const).
- Shared: TenantConfiguration.EnableRealmJoinWatcher (bool?, null=agent default
false) + AnalyzerConfiguration.EnableRealmJoinWatcher (bool=false)
- Backend: TableConfigRepository write/read; GetAgentConfigFunction maps
?? false into Analyzers; ConfigVersion 29 -> 30
- Web: types + TenantConfigContext (state/load/save/reset/deps/context value) +
SectionAgentAnalyzers + new "RealmJoin Watcher" subsection toggle
Inert until agent redeploy: old agents ignore the flag and stay always-on.
Verified: solution build 0 errors, tsc --noEmit clean, 76 backend config +
99 V2.Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V2 never wired the Legacy EnrollmentTracker's CollectAtEnrollmentStart /
CollectAtEnd (plan 5.8 TODO) - the device-info sweep ran only at agent
start. For IME-deployed agents the per-reboot restart re-ran CollectAll
and picked late values up eventually, but image-deployed agents
(--await-enrollment resumes the moment the MDM certificate appears,
i.e. at the very beginning of provisioning) collect an almost-empty
surface, and a session without a reboot between agent start and
completion never refreshed at all: aad_join_status stuck on
"Not Joined", autopilot profile / FirstSync ESP config stale, BitLocker
end state (enabled via policy DURING enrollment) never captured.
DeviceInfoHost now subscribes to the central signal stream (same
pattern as ProvisioningPackageHost) and mirrors the V1 behavior:
- CollectAtEnrollmentStart once on the first DeviceSetup phase signal
(re-fetches AAD join, autopilot profile, ESP config, TPM)
- CollectAtEnd once on FinalizingSetup or desktop arrival - desktop
arrival also serves the no-ESP / WDP v2 case and runs the
enrollment-start refresh if DeviceSetup was never seen
- repeat posts of the EspConfigDetected decision signal are explicitly
safe (reducer set-once with late-fill semantics)
This was deferred partly because every re-collect used to mean 4-6
duplicate events; with the StartupEventGate emit-on-change dedup the
re-collections only emit values that actually changed.
V2.Core tests 1410/1410 green (2 new); V2 runtime builds clean.
Inert until redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One enrollment session spans several reboots; every reboot restarts the
agent process and re-ran the one-shot startup checks, re-emitting ~20
events that carried no new information (device-info sweep, geo/timezone/
NTP probes, startup analyzers). Multi-reboot sessions multiplied this
into significant duplicate-event volume; the timezone probe even re-ran
tzutil on every boot.
New StartupEventGate (startup-event-state.json in the state directory,
fail-soft, atomic write, thread-safe) with two policies:
- emit-on-change: payload-fingerprint dedup for the 13 DeviceInfoCollector
events (central EmitDeviceInfoEvent hook; enrollment_type_detected now
routed through it too), software_inventory_analysis startup baseline,
local_admin_analysis (startup trigger only) and
integrity_bypass_analysis. Identical repeats are suppressed; a real
change (aad_join_status flipping after the Hybrid-Join reboot,
BitLocker conversion progressing) always re-emits. The gate only ever
wraps the EVENT emission - collection, decision signals and derived
facts (HasAadJoinedUser, EspConfigDetected reducer signal, inventory
delta base) run unchanged.
- retry-until-success: device_location, timezone_auto_set and
ntp_time_check skip entirely (no network call / tzutil) once they
succeeded in a previous run; failures keep retrying on the next start.
NTP latches only at offset <= 60s (shared constant with the severity).
Deliberately ungated: power_state_check (battery drain across the
enrollment is real per-boot information), boot_time, wifi_signal_info,
agent_started, and all shutdown emissions (the local-admin and
software-inventory start-vs-end delta comparisons stay intact).
The network_interface_info fingerprint excludes linkSpeedMbps (the
negotiated WiFi rate varies per association and would defeat the dedup).
One shared gate instance per process, created in AgentRuntimeHost and
passed to DefaultComponentFactory, AgentAnalyzerManager and
StartupEnvironmentProbes.
V2.Core tests 1408/1408 green (25 new); V2 runtime builds clean.
No ConfigVersion bump, no new event types; inert until redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OfficeInstallDetector state was in-memory only, so every agent
restart (an enrollment spans several reboots) re-armed the detector
from Idle and all three start triggers fired falsely on an
already-installed Office: Scenario\INSTALL persists post-install,
OfficeC2RClient.exe runs again for update checks, and Office updates
stream from the same CDN. Each restart then emitted a duplicate
started+completed pair (~0s duration) via the binary watcher's
initial scan; a mid-install reboot also reset the start time.
New OfficeInstallStatePersistence (office-install-state.json in the
state directory, fail-soft, atomic write) carries the lifecycle:
- persisted terminal (Completed/Failed) -> the factory does not arm
the detector at all on the next run (no WMI/RegNotify watchers)
- persisted Active -> the host resumes the open lifecycle without a
second started; a completion proven in this run reports the true
total duration plus the persisted DO peak as doSummary (also
catches completions missed across a mid-install reboot)
- AbandonSilently deliberately does NOT persist a terminal: it fires
on every dispose and would block that resume
- DO peak growth re-persists throttled (30s) while Active
V2.Core tests 1383/1383 green (14 new); V2 runtime builds clean.
No ConfigVersion bump (no new remote setting); inert until redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gate-starvation dead-ends (caa6cf50, 1ec8f4c6) were ultimately caused
by a single required user-targeted app that never started installing.
Until now the session just hung anonymously; this makes the starving app
a named, actionable customer hint.
New ImeLogTracker.GetStarvedUserEspApps(): required (Install/Uninstall
intent) apps in the current AccountSetup phase with no download/install
activity ever observed, not terminal, not failed. Apps that are alive
(Downloading/Installing) stay excluded; error apps belong to the failure
path. Same conservative thread-safety contract as AreUserEspAppsSettled.
Two timer-free trigger points, one-shot per appId:
- Live: when the ESP page exits and the user-apps-settled probe reports
false, EspAndHelloTracker emits app_install_starved (Warning) per
starved app; dedupe via a lock-guarded HashSet exposed as
StarvedAppsReported.
- Terminal: EnrollmentTerminationHandler sweeps leftovers on every
outcome (before app_tracking_summary, skipped on WhiteGlove Part 1) and
consults the live path dedupe set via the read-model surface, so no
app is ever reported twice.
Wiring: ImeLogHost bridge -> DefaultComponentFactory probe ->
EspAndHelloTracker; CollectorSurfaces + IAppTrackingReadModel +
OrchestratorAppTrackingReadModel expose the starved list and the
already-reported set to the termination handler.
New event type synced to MCP resource catalog (app_events) and Web
gather-rules eventTypes.
V2.Core 1365/1365, MCP vitest 220, Web 458, full solution build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the engine blocks or defers a completion attempt it now says what it
is still waiting on instead of going silent. New Info event
completion_waiting with data.missingPrerequisites built from stable
literals (account_setup_provisioning_complete, hello_resolution,
desktop_arrival, realmjoin_resolution), plus trigger, stage,
armedDeadlines and — on the guard-blocked esp_exiting site — the
AdvisoryCompletion window due-time.
Emission sites: CompleteThroughFinalizingOrDefer gate-closed defer,
HandleDesktopArrivedV1 desktop-first / blocked fast-path,
HandleEspExitingV1 post-AccountSetup guard-blocked exit,
HandleHelloResolvedV1 + HandleHelloSafetyDeadlineFired AwaitingDesktop
branches, HandleAccountSetupProvisioningCompleteV1 no-promote branch.
Pre-AccountSetup handoff exits stay silent (normal flow).
Dedupe via new additive-nullable state fact CompletionWaitingFingerprint
(comma-joined missing-set; ctor-end param, no schema bump — same pattern
as ImeUserSessionCompletedUtc in a77ad21e). A repeat with the same set
emits nothing; a set change emits one new event. SnapshotBuilder facts
16 -> 17; serializer roundtrip + legacy-snapshot compat covered.
New event type synced to MCP resource catalog (esp/enrollment group) and
Web gather-rules eventTypes.
DecisionCore 400/400, V2.Core 1347/1347, MCP vitest 220, Web 458, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DecisionEngine is edge-triggered: facts get recorded, but nothing
guarantees another signal ever re-asks the completion question — a session
can be parked with no deadline armed. The three known dead-end variants
(8bc1180f, caa6cf50, 1ec8f4c6) are covered by dedicated arming sites; this
adds the tripwire for the unknown variant 4.
DecisionStepProcessor now checks the post-step state: non-terminal, past
AccountSetup entry, dead-end zone entered (ESP final exit at-or-after
AccountSetup entry OR advisory-defanged failure recorded) and NO
resolution-capable deadline armed (ClassifierTick does not count — it
re-arms itself and never resolves a session). One-shot Warning event via
InformationalEventPost (wired through the LazyIngressSinkRelay), no state
schema touch, no auto-action. Expected to never fire in production; every
occurrence is a bug report.
AwaitingDesktop without a deadline fires by design (visible parking); a
DesktopSafety deadline stays a possible later iteration.
New event type synced to the MCP resource catalog (health_events).
V2.Core 1347/1347, DecisionCore 387/387, MCP vitest 220 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session 1ec8f4c6 exposed the third completion-dead-end variant: Windows closed
the ESP page NORMALLY (Shell-Core 62407, errorCode=0) while the AccountSetup
Apps subcategory was still in_progress (a user-targeted app never started).
No EspTerminalFailure ever fired, so the AdvisoryCompletion arming site from
the 8bc1180f fix never ran - yet the dead-end is identical: the registry gate
can never open, the IME settle probe is starved, Hello is disabled, and the
session idles to the max-lifetime watchdog although the full completion
conjunction (real-user desktop + Hello disabled + genuine IME user-session
completion) was satisfied minutes in.
HandleEspExitingV1 now arms the shared AdvisoryCompletion resolution window
when a guard-blocked esp_exiting lands AFTER AccountSetup entry (fire-once;
pre-AccountSetup handoff exits stay arm-free; a later advisory deliberately
re-bases the window). The deadline handler accepts the new anchor (final exit
at-or-after AccountSetup entry) alongside the advisory anchor and fails the
no-evidence case with its own semantics: reason
esp_exit_without_completion_evidence and LastFailureTrigger=DeadlineFired -
NOT esp_terminal_failure, and the likely-stuck app promotion stays off,
because the ESP never reported a failure.
Updated the two tests that pinned "guard-blocked exit arms nothing" to the
new contract - notably the caa6cf50 gate-starvation shape now carries the
resolution window as a 30-min backstop underneath the synthesis fast path.
DecisionCore 387/387, V2.Core 1335/1335 green; solution builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session 8bc1180f: Windows dumped 689 EventID-100 "Autopilot policy not found"
records in one minute. Each was downgraded to Debug but still forwarded as an
individual modern_deployment_log event - 701 of 1116 session events (63%),
signal-ingress queue saturated (ingress_backpressure 256/256), and the burst
deferred the periodic-collector idle stop.
Two changes:
- ModernDeploymentTracker: harmless-downgraded EventIds (default 100/1005/1010,
Level 2/3 -> Debug) are now rollup-gated per EventId - first 3 occurrences
forwarded individually, afterwards only every 100th, carrying the cumulative
occurrenceCount (plus a rollup marker in Data). Counter-based, no timers;
warnings/errors and non-harmless IDs are untouched. The 689-burst now
produces 9 events instead of 689.
- SignalActivityClassifier: modern_deployment_log added to the non-activity
denylist - OS-eventlog forwarding is observability, not enrollment progress,
and a policy-cache re-read burst must not keep the periodic collectors
alive. modern_deployment_warning/error remain real activity.
V2.Core rollup + classifier tests 33/33 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An advisory-defanged EspTerminalFailure (ContinueAnyway + AccountSetup entered)
deliberately keeps the session non-terminal so the normal completion paths can
still win. Session 8bc1180f showed the dead-end variant: AccountSetup/Apps
failed (0x87d1041c), so AccountSetupProvisioningComplete can never arrive, the
IME settle probe was starved by a never-started user app, and Hello was
disabled - no completion path left. The session idled InProgress until the
max-lifetime watchdog, which by design issues no session verdict.
The advisory path now arms a 30-min AdvisoryCompletion deadline (AgentBoot-
floored, not re-armed by enrichment duplicates). On fire the engine resolves
the session either way:
- Completed (through Finalizing) when the real-user conjunction holds:
desktop_arrived (DAD-validated, defaultuser0/SYSTEM excluded at the
detector) AND Hello resolved-or-policy-disabled AND the new
ImeUserSessionCompletedUtc fact at-or-after AccountSetupEnteredUtc. The
IME timestamp gate kills defaultuser0 ghosts - OOBE/technician IME sessions
complete in the pre-AccountSetup frame and can never satisfy it.
- Failed otherwise, un-defanging the advisory: enrollment_failed with
reason=esp_terminal_failure and LastFailureTrigger=EspTerminalFailure so the
EnrollmentTerminationHandler likely-stuck app promotion keeps firing.
ImeUserSessionCompletedUtc is an additive nullable DecisionState fact
(set-once in HandleImeUserSessionCompletedV1, serialized snapshot-compatible,
surfaced in the signal census as ime_user_session_completed).
DecisionCore.Tests 381/381 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session 4d5a0b78: WMI Win32_Process.GetOwner failed on every single DAD poll
(wmiErrorCount == pollCount from the first poll on), so the explorer.exe
owner was never resolved, desktop_arrived never fired, and the Desktop half
of the completion AND-gate starved — the session sat in AwaitingDesktop
forever even though the gate-starvation synthesis (d3abbacd) had correctly
opened the AccountSetup gate.
Owner resolution is now WTSQuerySessionInformation(WTSUserName/WTSDomainName)
keyed by the process's session id as the primary path — a single kernel
round-trip with no WinMgmt dependency, and semantically the right question
anyway (explorer.exe runs as the session's logged-on user). The previous WMI
GetOwner path remains as fallback when WTS yields no user.
- New WtsNativeMethods interop (wtsapi32) with buffer-safe QuerySessionString
- DesktopArrivalDetector.ResolveOwner: WTS first, WMI fallback, injectable
test seams (SessionOwnerResolver/ProcessOwnerResolver)
- first_poll/no_candidate liveness payloads now carry wtsErrorCount alongside
wmiErrorCount so the broken path is visible per device
V2.Core 1327/1327 green; WTS path live-verified (AzureAD\user shape matches
the existing IsExcludedUser contract).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session caa6cf50: a policy-skipped user-ESP app leaves the ESP registry's
Apps subcategory permanently inProgress, so Windows never writes
AccountSetupCategory.Status.categorySucceeded before tearing down the ESP
page. Both registry-driven AccountSetupProvisioningComplete paths (normal +
all-subcategories fallback) starve, ShouldTransitionToAwaitingHello never
opens, and the session stalls after 60 min despite a successful enrollment
(esp_exiting + desktop_arrived + helloPolicy=false all recorded).
Fix, in three parts:
- ImeLogTracker.AreUserEspAppsSettled(): conservative probe — true only in
AccountSetup phase with >=1 required app, all required apps terminal
(Installed/Skipped/Postponed) and zero errors. Exposed via ImeLogHost.
- EspAndHelloTracker: on every Shell-Core exit forward, fire-once synthesis
of AccountSetupProvisioningComplete when the probe reports settled, plus
an esp_provisioning_status observability event
(fallbackReason=esp_exited_user_apps_settled_category_unresolved).
Wired through a new optional userEspAppsSettledProbe ctor param;
DefaultComponentFactory connects it to ImeLogHost via closure.
- HandleAccountSetupProvisioningCompleteV1: deferred-completion parity.
When the strong gate arrives last (Win11 ordering: explorer.exe runs
under the User-ESP page, so Desktop arrives before the final exit) and
Hello is resolved or policy-disabled, complete through Finalizing with
HelloOutcome="Skipped" instead of parking in AwaitingHello until
HelloSafety stamps a misleading "Timeout" 300 s later.
Sessions with failed or in-flight user apps keep the existing conservative
stall behaviour. DecisionCore 366/366, V2.Core 1321/1321 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RealmJoin.exe inverts the usual .NET SDK version-resource layout: the
FileVersion STRING carries the full SemVer including the channel tag
("4.21.6-canary+476277.d320cac0") while ProductVersion is the bare
"4.21.6". The previous ProductVersion-first read therefore never saw
the channel.
- RealmJoinInfo: read FileVersion first, parse SemVer into bare version
+ release channel (prerelease tag = channel; untagged = "release",
confirmed by the RJ developer; build metadata dropped)
- realmjoin_detected signal + timeline event carry releaseChannel
- RealmJoinFacts.ReleaseChannel set-once fact + audit-trail field
realmjoinReleaseChannel
Suites: DecisionCore 360, V2.Core 1307 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EnrollmentTerminationHandler's 25-parameter constructor (17 optional,
9 Func<> accessors, wired via ~80 lambda lines in AgentRuntimeHost)
becomes 11 parameters: four cohesive surfaces plus direct infra deps.
- TerminationSessionContext (concrete, pure data): configuration,
stateDirectory, agentStartTimeUtc, agentVersion, sessionPersistence,
dialogExePathOverride, lazy IsWhiteGlovePart2.
- IAppTrackingReadModel: currentState, packageStates, appTimings,
ignoredCount, promoteActiveInstallsToStuck, lastEspTerminalFailure.
Production: OrchestratorAppTrackingReadModel over the orchestrator's
CollectorSurfaces (builds on ARCH-F4).
- IDrainStatus: spool/ingress drain counts behind CanObserve* capability
flags (replaces the accessor-null wiring variants); production:
OrchestratorDrainStatus with the V1-parity 10s budget.
- IShutdownGate: signalShutdown, triggerReboot, writeCleanExitMarker,
tryClaimShutdownEvent; production: RuntimeShutdownGate in the host
project (owns the shutdown.exe logic formerly DefaultTriggerReboot).
Per-parameter rationale comments (c117946b, 080edee9, 7dd4e593, Codex
Finding 2, Option 1/2 hardening, plan §11) moved onto the interface
members. Test rig builds recording fakes of the same interfaces, so the
optional-default wiring variants production never ran are gone.
No behavior change. V2.Core 1297/1297, DecisionCore 359/359, full
solution build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateCollectorHosts now returns an immutable CollectorSurfaces bundle
(hosts + typed live read-model views) and takes the telemetry spool as a
parameter. Deletes DefaultComponentFactory's mutable host fields, all
lazy read-model properties, and SetTelemetrySpool; deletes the
'is DefaultComponentFactory' downcast in EnrollmentOrchestrator (the
IComponentFactory seam is the contract again). The orchestrator stores
the surfaces and exposes them via CollectorSurfaces; AgentRuntimeHost
reads IME app tracking / EspAndHelloHost / AadJoinHost from there.
No behavior change. V2.Core tests 1297/1297 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P2 (MON-C2): HelloTracker.ResetForEspResumption did not clear the new
_helloUnknownGraceUsed one-shot flag. After a grace-consumed run, a fresh
post-resume Hello-wait that hit an undetected policy would skip its grace
re-arm and resolve straight to not_configured. Reset the flag + add a
regression test (unknown timeout -> reset -> unknown timeout re-grants grace).
P3 (ARCH-F1): the completion-gate docs overpromised "add a gate to
s_completionGates and every site inherits it". Tightened to state precisely
that the collection governs only the completion-ATTEMPT sites routed through
CompleteThroughFinalizingOrDefer; the RealmJoin release path (pre-resolution
state) and the SelfDeploying deadline path (gate-specific deferral) do NOT
consult the collection and need explicit wiring for a second gate. Doc-only.
V2.Core.Tests 1297/1297; DecisionCore.Tests 359/359.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 4 / ARCH-F1. The cross-cutting "may the session complete now?" AND-gate
was hand-inlined at every terminal site across the Classic / Shared engine
partials — each with a near-identical RealmJoinGateOpen check plus its own
":RealmJoinGateClosed" defer block. That duplication was the origin of the
premature/duplicate enrollment_complete regressions (8b8d611d, 08c99638,
330f73f3) and meant a second completion gate (WDP v2 device-association
settle) would have to be woven into every site.
New DecisionEngine.Completion.cs introduces:
* CompletionGate (name + IsOpen predicate) + an append-only s_completionGates
collection (RealmJoin is the sole gate today) + FirstClosedCompletionGate.
* CompleteThroughFinalizingOrDefer — the single router every "both
prerequisites in -> finish via Finalizing" site now calls. All gates open
-> TransitionToFinalizing; a gate closed -> the canonical defer step with
the trigger tagged ":<GateName>Closed" (derived from the gate, not
hardcoded).
The four identical Classic/Shared sites (HelloResolved, DesktopArrived normal
+ Hello-disabled fast-path, Hello-safety timeout) now route through the helper.
The SelfDeploying deadline path keeps its gate-specific deferral (it owns the
SelfDeployingDeferredCompletion flag, released by the RealmJoin handlers); the
RealmJoin release path completes directly because the completion gates read the
pre-resolution state where RJ is still closed.
No behavior change — proven by the replay-deterministic scenario tests passing
bit-identically (same terminal stages, outcomes, hashes). Adding a gate is now
one entry in s_completionGates. New forward-proof test locks the gate-name ->
trigger-suffix derivation.
DecisionCore.Tests: 359/359 (was 358 + 1 new). Full solution builds; V2.Core
engine integration green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HelloTracker.OnHelloWaitTimeout branched on `if (_isHelloPolicyEnabled)`,
whose false case force-resolved HelloOutcome=not_configured and fired
HelloCompleted. But `_isHelloPolicyEnabled == false` also holds when the
policy has simply not been detected yet (_isPolicyConfigured == false) —
a slow MDM/CSP sync that lands the WHfB policy read after the 30s wait
would then prematurely resolve Hello on a device that is actually
configured.
Make the timeout a 3-way decision:
* policy known ENABLED -> extended wait (long completion timer)
* policy NOT YET DETECTED -> one bounded grace re-arm (~wait window)
so a slow policy sync can land; only
then fall through to not_configured
* policy known DISABLED (or still
unknown after the grace) -> not_configured completion (unchanged)
The bounded grace (a single re-arm of the existing one-shot wait timer)
is deliberately NOT the 300s completion timer: DetectHelloPolicy returns
(null,null) forever on a genuinely-unconfigured device, so an unconditional
extended wait would add a 5-minute completion delay to every such device.
The grace caps the extra wait and still closes the race.
Telemetry: hello_wait_timeout events now carry policyDetected + a distinct
action ("unknown_policy_grace") so the undetected path is observable.
V2.Core.Tests: 1296/1296 (3 new MON-C2 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 4 / ARCH-F7. HelloTrackerAdapter, ShellCoreTrackerAdapter and
ProvisioningStatusTrackerAdapter had zero production instantiations —
production runs solely through EspAndHelloTrackerAdapter (wired in
EspAndHelloHost), which already exposes every Trigger*FromTest seam the
sub-adapter tests exercised. Tests on the sub-adapters proved nothing
about production behavior.
- Delete the 3 sub-adapter sources + their test files.
- Port the meaningful coverage to EspAndHelloTrackerAdapterTests:
HelloOutcome verbatim-propagation theory + empty-failureType fallback.
- Rewrite ShellCoreToEngineEspExitingTests test 1 to assert the tracker's
EspExited contract directly (HandleBackfillRecord -> EspExited + source
timestamp); drop the redundant adapter-driven reducer test (coordinator
test 5 already covers the full production chain).
- Drop the duplicate ProvisioningStatusTrackerAdapter test in
EspFailureDetectedHResultTests (coordinator test above it is identical).
- Refresh stale ShellCoreTrackerAdapter references in ShellCoreTracker +
DefaultComponentFactory doc comments.
V2.Core.Tests: 1293/1293 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent never sent X-Correlation-ID, so the backend's CorrelationIdMiddleware
minted a fresh id per inbound request — meaning a single logical upload that
fans out into multiple backend executions could not be tied back together (the
blocker that left the IngestTelemetry request-multiplication question
unanswerable). Wire up the correlation the infra was designed for:
- BackendTelemetryUploader: generate a unique X-Correlation-ID per upload (per
attempt, so a retry is a distinct traceable request) and log `corr=` on every
outcome line (flushing/OK/TRANSIENT/TIMEOUT/NETWORK/PERMANENT).
- BackendApiClient.AddSecurityHeaders: same per-request id for register/config/
diag/error, with a Debug line carrying corr + path.
Payoff: `requests | where name=='IngestTelemetry' | summarize count() by
tostring(customDimensions.CorrelationId)` now gives the per-request
multiplication factor deterministically (1 = clean, N = multiplied).
Test: updated the uploader logging assertion to the new `ingest OK (corr=` +
`items=N` format. Full V2.Core suite 1266/1266 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to 28885f7b. RequestStop() called UnregisterWait(), nulling
_registeredWait. When a Changed handler calls RequestStop() and then
queues Stop()/Dispose() to another thread while the callback is still
running — exactly the RealmJoinWatcher appearance-handler shape — the
queued Stop() then saw a null _registeredWait, skipped the blocking
Unregister(waitObject), and returned BEFORE the in-flight callback
finished. That defeated the Thread.Join() parity guarantee on the most
important RequestStop path.
RequestStop() now only sets _stopped and Set()s the signal, leaving the
live registration in place so Stop()/Dispose() can wait on it. The Set()
wakes any still-pending wait, which drains as a no-op (proceed is false
once _stopped is set); a callback already in flight is awaited by Stop().
No deadlock: Stop runs on a different thread and never holds _sync during
the wait.
Test: deterministic guard — park the callback inside the handler, call
RequestStop(), then assert Stop() blocks until the handler completes
(release fired 250ms later on another thread). Proven to FAIL when the
UnregisterWait() is reintroduced into RequestStop(). Full V2.Core suite
1266/1266 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings on the ThreadPool-wait RegistryWatcher (993ebbaa):
P1 (correctness, would silently break in production): force
REG_NOTIFY_THREAD_AGNOSTIC into the effective filter regardless of the
caller's argument. Without a dedicated long-lived thread, the first
RegNotifyChangeKeyValue is issued on the transient Start() caller thread
and re-armed on rotating ThreadPool threads; without this flag the kernel
ties the registration to the issuing thread and cancels the notification
once it ends/recycles. AadJoinWatcher / ProvisioningStatusTracker /
RealmJoinWatcher all pass filters WITHOUT the flag — OR'ing it in the
ctor keeps them correct. (Win8+ only; all Autopilot devices are Win10+.)
P2 (semantic parity with the old Thread.Join): Stop() now issues a
blocking Unregister(waitObject) and waits for any in-flight OnSignalled
callback to finish BEFORE disposing _signal / _keyHandle, so Changed can
no longer fire (nor a freed handle be used) after Stop()/Dispose()
returns. The wait runs outside _sync so the callback's re-arm path can
still observe _stopped; it is deadlock-free because Stop() is never
called from within a handler (callers use RequestStop or queue Stop to
another thread). OnSignalled no longer nulls _registeredWait up front so
Stop can wait on the live registration.
Test: deterministic guard asserting the watcher forces ThreadAgnostic
into its effective filter for callers that omit it. A behavioural
thread-death test is unreliable — without the flag the kernel emits a
*spurious* signal on arming-thread death that a Changed-based test cannot
distinguish from a real change. Full V2.Core suite 1265/1265 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce the V2 agent footprint during enrollment without touching any
capability or the deliberate live-tracking cadences (DO 3s poll, IME
100ms poll, download_progress ImmediateUpload — all product USP).
RegistryWatcher: replace the dedicated background thread (new Thread +
WaitHandle.WaitAny on RegNotifyChangeKeyValue) with a ThreadPool wait
(ThreadPool.RegisterWaitForSingleObject), mirroring the proven pattern
in Monitoring/Telemetry/Office/RegistryChangeWatcher. Removes up to
~7-8 dedicated ~1MB thread stacks (Enrollments + CloudDomainJoin +
AadJoin + Provisioning + up to 4x RealmJoin). The public contract is
preserved verbatim: ctor signature, Changed/Error events, IsRunning,
Start()/RequestStop()/Stop() (incl. deferred rethrow of the captured
background failure) and Dispose(). Arming stays synchronous in Start();
Changed is raised outside the lock; the watcher re-arms after each
single-shot notification. All four consumers run unchanged.
Disposal hygiene:
- WmiCollector: dispose each ManagementObject per iteration (native
WMI/COM memory) via using (obj).
- AgentSelfMetricsCollector: wrap both Process.GetCurrentProcess() in
using var (handle leak in the 60s metrics loop).
Verified: V2.Core + V2 agent build clean; full V2.Core suite 1264/1264
green, incl. the RegistryWatcher Win32 integration tests (value write,
subtree subkey, repeated successive changes) and the RealmJoin /
AadJoin / Provisioning / TenantId consumer tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>