OOBE quality update: how it ended, a begin without the page start, three rules for the update that did not happen (D-320)

Field measurement 3: a first-run agent that started after the update page (6a7d241d) left the whole
update in Apps (ESP), failed and skipped updates were reported by nothing, and the lane listed a
failed update's staged servicing stack as an installed KB.

- Time attribution v5: update names before the first observed OobeNDUP start begin the update at the
  earlier of the first name and the update's first servicing step (after the window's ESP exit when
  one was seen before the name, else the first step of a package the update also serviced later).
  The fallback's ESP exit is per window, so a WhiteGlove part-1 exit no longer begins an update in
  part 2. OsUpdateSpan carries Outcome (installed/failed/skipped/unknown, the latest evidence wins)
  and NotInstalledKbs; Kbs lists only packages that reached Installed. The 2-h sweep recomputes the
  last 30 days.
- ANALYZE-DEV-013 (update failed), DEV-014 (not offered after an earlier failure), DEV-015 (update
  check ended without a result): warning, never KO; a failure only from explicit names, every
  absence condition gated on the agent's names_capped marker; never two at once.
- ShellCoreTracker rows never set the session start (D-315 list), ahead of the agent's first-run
  update page backfill.
- The web lane names the outcome and the KBs not installed; MCP get_time_attribution mentions both.

Real-event replay: 6a7d241d os_update 2358 s installed (was 55 min in esp_apps), e7551b82 failed with
KB5124007 not installed. Rule dry runs and a sweep over all 97 update-page sessions since 2026-10-02
hit exactly the five field sessions. Functions 6,918, web 1,701, MCP 997 green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-05 11:26:57 +02:00
parent 81d0fe6ed3
commit 46058ffb90
22 changed files with 1983 additions and 72 deletions
+118
View File
@@ -0,0 +1,118 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-013",
"title": "OOBE Quality Update Failed",
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "in",
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "os_build_unchanged",
"source": "event_type",
"eventType": "os_build_changed",
"operator": "not_exists",
"value": "",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
},
{
"signal": "update_page_failed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "result",
"operator": "equals",
"value": "fail",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": false
},
{
"signal": "update_skipped",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "regex",
"value": "SkipDownloadInstallButtonClicked$",
"required": false
}
],
"confidenceFactors": [
{
"signal": "update_page_failed",
"condition": "exists",
"weight": 10
}
],
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
"remediation": [
{
"title": "Find out why the update failed",
"steps": [
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
]
},
{
"title": "Make sure the device gets the update",
"steps": [
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
}
+119
View File
@@ -0,0 +1,119 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-014",
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "previous_failures",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
"required": true
},
{
"signal": "nothing_offered",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "false",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "no_update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "in",
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
"remediation": [
{
"title": "Check the earlier attempt",
"steps": [
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
},
{
"title": "Reduce how far behind devices start",
"steps": [
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
}
+117
View File
@@ -0,0 +1,117 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-015",
"title": "OOBE Update Check Ended Without a Result",
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_allowed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "true",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
"required": true
},
{
"signal": "scan_started",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "equals",
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
"required": true
},
{
"signal": "no_scan_result",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "^ExpeditedUpdate_getUpdateResults",
"required": true
},
{
"signal": "update_page_ended",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "equals",
"value": "page_stopped",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
"remediation": [
{
"title": "Check the path to Windows Update during OOBE",
"steps": [
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
]
},
{
"title": "Shorten the scan",
"steps": [
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
}
+351
View File
@@ -1930,6 +1930,357 @@
"environment"
]
},
{
"ruleId": "ANALYZE-DEV-013",
"title": "OOBE Quality Update Failed",
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "in",
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "os_build_unchanged",
"source": "event_type",
"eventType": "os_build_changed",
"operator": "not_exists",
"value": "",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
},
{
"signal": "update_page_failed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "result",
"operator": "equals",
"value": "fail",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": false
},
{
"signal": "update_skipped",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "regex",
"value": "SkipDownloadInstallButtonClicked$",
"required": false
}
],
"confidenceFactors": [
{
"signal": "update_page_failed",
"condition": "exists",
"weight": 10
}
],
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
"remediation": [
{
"title": "Find out why the update failed",
"steps": [
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
]
},
{
"title": "Make sure the device gets the update",
"steps": [
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
},
{
"ruleId": "ANALYZE-DEV-014",
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "previous_failures",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
"required": true
},
{
"signal": "nothing_offered",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "false",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "no_update_failure",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "in",
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
"required": true
},
{
"signal": "no_install_success",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
"remediation": [
{
"title": "Check the earlier attempt",
"steps": [
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
},
{
"title": "Reduce how far behind devices start",
"steps": [
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
},
{
"ruleId": "ANALYZE-DEV-015",
"title": "OOBE Update Check Ended Without a Result",
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
],
"trigger": "single",
"baseConfidence": 75,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "update_allowed",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "equals",
"value": "true",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
"required": true
},
{
"signal": "scan_started",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "equals",
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
"required": true
},
{
"signal": "no_scan_result",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "name",
"operator": "not_exists",
"value": "",
"filterField": "name",
"filterOperator": "regex",
"filterValue": "^ExpeditedUpdate_getUpdateResults",
"required": true
},
{
"signal": "update_page_ended",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "equals",
"value": "page_stopped",
"filterField": "page",
"filterOperator": "equals",
"filterValue": "OobeNDUP",
"required": true
},
{
"signal": "days_out_of_date",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "value",
"operator": "gt",
"value": "0",
"filterField": "name",
"filterOperator": "equals",
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
"required": true
},
{
"signal": "page_names_complete",
"source": "event_data",
"eventType": "oobe_update_page",
"dataField": "cxhEvent",
"operator": "not_exists",
"value": "",
"filterField": "cxhEvent",
"filterOperator": "equals",
"filterValue": "names_capped",
"required": true
}
],
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
"remediation": [
{
"title": "Check the path to Windows Update during OOBE",
"steps": [
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
]
},
{
"title": "Shorten the scan",
"steps": [
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"windows-update",
"oobe",
"quality-update"
]
},
{
"ruleId": "ANALYZE-ENRL-001",
"title": "Enrollment Failed",
@@ -0,0 +1,481 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The OOBE quality update page (oobe_update_page, D-310) — pins the built-in analyze rules for
/// the update that did not happen:
/// - ANALYZE-DEV-013: the page reported a failed download or install and no install success
/// (field shapes e7551b82, cb1992b8). Only the explicit failure names count: an update that
/// installed logs downloadInstallFailureHelper and a start timeout too (e4ecd6f8).
/// - ANALYZE-DEV-014: nothing offered to an out-of-date device whose page counts an earlier
/// failure, without a failure in this session (b2d9f12c).
/// - ANALYZE-DEV-015: the scan started and the page ended without a scan result (c5b20dfa).
/// Every absence condition is vetoed by the agent's names_capped marker. Sequences follow the
/// field sessions of 2026-10-03..05 (synthetic times, no customer data); a session never fires
/// two of the three.
/// </summary>
public class OobeUpdateRuleTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "11111111-2222-3333-4444-555555555555";
private static readonly DateTime T0 = new(2026, 10, 3, 16, 56, 59, DateTimeKind.Utc);
private const string DownloadFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError";
private const string InstallFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError";
private const string CommitFailed = "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure";
private static readonly string[] RuleIds = { "ANALYZE-DEV-013", "ANALYZE-DEV-014", "ANALYZE-DEV-015" };
// ── the rules ───────────────────────────────────────────────────────────
[Theory]
[InlineData("ANALYZE-DEV-013")]
[InlineData("ANALYZE-DEV-014")]
[InlineData("ANALYZE-DEV-015")]
public void Rules_are_enabled_warnings_that_never_fail_the_session(string ruleId)
{
var rule = Rule(ruleId);
Assert.True(rule.Enabled);
Assert.Equal("warning", rule.Severity);
Assert.Equal("device", rule.Category);
Assert.False(rule.MarkSessionAsFailedDefault); // the enrollment continues without the update
}
// ── ANALYZE-DEV-013: failed ─────────────────────────────────────────────
[Fact]
public async Task DEV_013_fires_on_a_failed_download_and_names_the_failure()
{
var outcome = await RunAsync(FailedDownload());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-013", result.RuleId);
Assert.Equal(DownloadFailed, Value(result, "update_failure")); // {{update_failure}}
Assert.Equal("fail", Value(result, "update_page_failed"));
Assert.Equal(85, result.ConfidenceScore);
}
[Fact]
public async Task DEV_013_fires_when_the_update_is_skipped_after_the_failure()
{
var outcome = await RunAsync(FailureThenSkip());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-013", result.RuleId);
Assert.Equal(DownloadFailed, Value(result, "update_failure"));
Assert.Equal("CloudNDUPSkipDownloadInstallButtonClicked", Value(result, "update_skipped"));
}
[Theory]
[InlineData(InstallFailed)]
[InlineData(CommitFailed)]
public async Task DEV_013_fires_on_every_explicit_failure_name(string failureName)
{
var events = Visit(days: 120, previousFailures: 0);
events.AddRange(new[] { Name("ExpeditedUpdate_getUpdateResultsSucceeded"), Name(failureName), PageStop("OobeNDUP", "fail") });
var result = Assert.Single((await RunAsync(events)).Results);
Assert.Equal(failureName, Value(result, "update_failure"));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task DEV_013_stays_silent_on_an_update_that_installed_despite_the_failure_helper(bool buildChangeObserved)
{
// Field shape e4ecd6f8: the failure helper and a start timeout after "restart required",
// then the update installed — with or without the build change on record.
var events = InstalledDespiteFailureHelper();
if (!buildChangeObserved) events.RemoveAll(e => e.EventType == "os_build_changed");
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task DEV_013_stays_silent_when_a_retry_installed_the_update()
{
var events = FailedDownload();
events.AddRange(new[]
{
PageStart("OobeNDUP"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu"),
});
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task DEV_013_is_vetoed_by_an_os_build_change()
{
var events = FailedDownload();
events.Add(OsBuildChanged());
Assert.Empty((await RunAsync(events)).Results);
}
// ── ANALYZE-DEV-014: not offered after an earlier failure ───────────────
[Fact]
public async Task DEV_014_fires_when_nothing_is_offered_after_an_earlier_failure()
{
var outcome = await RunAsync(NothingOfferedAfterFailure());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-014", result.RuleId);
Assert.Equal("137", Value(result, "days_out_of_date")); // {{days_out_of_date}}
Assert.Equal("1", Value(result, "previous_failures")); // {{previous_failures}}
}
[Theory]
[InlineData(0, 1)] // the device is current: nothing to offer
[InlineData(137, 0)] // no earlier failure on record
public async Task DEV_014_stays_silent_without_both_the_backlog_and_the_earlier_failure(int days, int previousFailures)
{
var events = Visit(days, previousFailures);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop("OobeNDUP", "cancel"),
});
Assert.Empty((await RunAsync(events)).Results);
}
// ── ANALYZE-DEV-015: scan without a result ──────────────────────────────
[Fact]
public async Task DEV_015_fires_when_the_page_ends_without_a_scan_result()
{
var outcome = await RunAsync(ScanWithoutResult());
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-015", result.RuleId);
Assert.Equal("438", Value(result, "days_out_of_date"));
Assert.Equal("page_stopped", Value(result, "update_page_ended"));
}
[Fact]
public async Task DEV_015_stays_silent_while_the_page_still_runs()
{
// The session ended during the scan: no page end, so no claim that the page gave up.
var events = ScanWithoutResult();
events.RemoveAll(e => e.EventType == "oobe_update_page" && (string)e.Data["cxhEvent"] == "page_stopped");
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task DEV_015_stays_silent_when_the_agent_missed_the_scan_start()
{
// A late agent sees the page end but not the scan: the missing result proves nothing.
var events = ScanWithoutResult();
events.RemoveAll(e => e.EventType == "oobe_update_page" && (e.Data.TryGetValue("name", out var n) ? (string)n : "").StartsWith("ExpeditedUpdate_startWUScan"));
Assert.Empty((await RunAsync(events)).Results);
}
// ── shared vetoes and the sessions that fire nothing ────────────────────
[Theory]
[MemberData(nameof(FiringShapes))]
public async Task The_names_capped_marker_vetoes_every_rule(string shape)
{
// Once the agent capped the page's names, a missing success or scan result proves nothing.
var events = Shapes[shape]();
events.Add(NamesCapped());
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task A_page_the_policy_switched_off_fires_nothing()
{
var events = new List<EnrollmentEvent>
{
PageStart("OobeNDUP"),
Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
};
Assert.Empty((await RunAsync(events)).Results);
}
[Fact]
public async Task An_installed_update_fires_nothing()
{
Assert.Empty((await RunAsync(Installed())).Results);
}
[Theory]
[MemberData(nameof(AllShapes))]
public async Task No_session_fires_more_than_one_of_the_three(string shape)
{
var outcome = await RunAsync(Shapes[shape]());
Assert.True(outcome.Results.Count <= 1, $"{shape}: {string.Join(", ", outcome.Results.Select(r => r.RuleId))}");
}
// ── session shapes (field sessions, synthetic times) ────────────────────
private static readonly Dictionary<string, Func<List<EnrollmentEvent>>> Shapes = new()
{
["failed_download"] = FailedDownload,
["failure_then_skip"] = FailureThenSkip,
["installed_despite_failure_helper"] = InstalledDespiteFailureHelper,
["nothing_offered_after_failure"] = NothingOfferedAfterFailure,
["scan_without_result"] = ScanWithoutResult,
["installed"] = Installed,
};
public static IEnumerable<object[]> AllShapes() => Shapes.Keys.Select(k => new object[] { k });
public static IEnumerable<object[]> FiringShapes() =>
new[] { "failed_download", "failure_then_skip", "nothing_offered_after_failure", "scan_without_result" }
.Select(k => new object[] { k });
/// <summary>The names every visit logs before its scan.</summary>
private static List<EnrollmentEvent> Visit(int days, int previousFailures) => new()
{
PageStart("OobeNDUP"),
Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
Name("ExpeditedUpdate_getDaysOutOfDateSucceeded", days.ToString()),
Name("SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", "false"),
Name("SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", previousFailures.ToString()),
Name("ExpeditedUpdate_startWUScanStarted"),
Name("ExpeditedUpdate_startWUScanStartedSuccedded"),
};
/// <summary>e7551b82: only the servicing stack staged, the download failed after 25 min; the second visit found nothing to offer.</summary>
private static List<EnrollmentEvent> FailedDownload()
{
var events = Visit(days: 136, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsStarted"),
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"), // a list: no safe value
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name(DownloadFailed),
Name(CommitFailed),
Name("SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
PageStop("OobeNDUP", "fail"),
PageStart("RebootNDUP"),
});
events.AddRange(Visit(days: 137, previousFailures: 1));
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>cb1992b8: visit 1 failed; visit 2 downloaded again and someone selected Skip before the install.</summary>
private static List<EnrollmentEvent> FailureThenSkip()
{
var events = Visit(days: 438, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name(DownloadFailed),
Name(CommitFailed),
Name(InstallFailed),
PageStop("OobeNDUP", "fail"),
PageStart("RebootNDUP"),
});
events.AddRange(Visit(days: 438, previousFailures: 1));
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("CloudNDUPOptoutLinkClicked", "0"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Name("SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress100"),
Name("SdxWebAppCloudNDUP_manageProgressBars_waitingForInstallStartError"),
Name("CloudNDUPSkipDownloadInstallButtonClicked", "0"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>e4ecd6f8: the failure helper and a start timeout after "restart required" — the update installed after three restarts.</summary>
private static List<EnrollmentEvent> InstalledDespiteFailureHelper()
{
var events = Visit(days: 99, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("SdxWebAppCloudNDUP_updateUSOProgressBar_installPhase_progress100"),
Name("SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
Name("SdxWebAppCloudNDUP_startEventArrivalTimeout_TimerExpired"),
PageStart("RebootNDUP"),
OsBuildChanged(),
PageStart("OobeNDUP"),
PageStop("OobeNDUP", "success"),
});
return events;
}
/// <summary>b2d9f12c: the same device the next day — out of date, an earlier failure on record, nothing offered.</summary>
private static List<EnrollmentEvent> NothingOfferedAfterFailure()
{
var events = Visit(days: 137, previousFailures: 1);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsStarted"),
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
Name("SdxWebAppCloudNDUP_exit_appResult"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>c5b20dfa: 438 days behind; the page ended two minutes after the scan started, without a result.</summary>
private static List<EnrollmentEvent> ScanWithoutResult()
{
var events = Visit(days: 438, previousFailures: 0);
events.AddRange(new[]
{
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededNoReboot"), // the language check, every visit
Name("SdxWebAppCloudNDUP_exit_appResult"),
PageStop("OobeNDUP", "cancel"),
PageStart("RebootNDUP"),
PageStop("RebootNDUP", "success"),
});
return events;
}
/// <summary>6a7d241d: downloaded, installed, restarted.</summary>
private static List<EnrollmentEvent> Installed()
{
var events = Visit(days: 120, previousFailures: 0);
events.AddRange(new[]
{
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired"),
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
Name("SdxWebAppCloudNDUP_rebootCountdown_starting"),
PageStart("RebootNDUP"),
OsBuildChanged(),
});
return events;
}
// ── event builders — mirror the agent's OobeUpdateTelemetry payload ─────
private static long _seq;
private static EnrollmentEvent Evt(string eventType, Dictionary<string, object> data)
{
var seq = Interlocked.Increment(ref _seq);
return new EnrollmentEvent
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = eventType,
Timestamp = T0.AddSeconds(seq),
Sequence = seq,
Data = data,
};
}
private static Dictionary<string, object> PageData(string cxhEvent, int windowsEventId) => new()
{
["cxhEvent"] = cxhEvent,
["windowsEventId"] = windowsEventId,
["backfill"] = false,
["occurrence"] = 1,
};
private static EnrollmentEvent PageStart(string page)
{
var data = PageData("page_started", 62404);
data["page"] = page;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent PageStop(string page, string result)
{
var data = PageData("page_stopped", 62405);
data["page"] = page;
data["result"] = result;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent Name(string name, string? value = null)
{
var data = PageData("event_name", value == null ? 62406 : 62407);
data["name"] = name;
if (value != null) data["value"] = value;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent NamesCapped()
{
var data = PageData("names_capped", 0);
data.Remove("windowsEventId");
data.Remove("occurrence");
data["limit"] = 150;
return Evt("oobe_update_page", data);
}
private static EnrollmentEvent OsBuildChanged() => Evt("os_build_changed", new Dictionary<string, object>
{
["previousBuild"] = "26200.8037",
["currentBuild"] = "26200.8655",
});
// ── harness (as WindowsUpdateRuleTests) ─────────────────────────────────
private static AnalyzeRule Rule(string ruleId) => BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == ruleId);
private static string Value(RuleResult result, string signal)
{
var evidence = Assert.IsType<Dictionary<string, object>>(result.MatchedConditions[signal]);
return evidence["value"]?.ToString() ?? string.Empty;
}
private static async Task<AnalysisOutcome> RunAsync(List<EnrollmentEvent> events)
{
var rules = RuleIds.Select(Rule).ToList();
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(rules);
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<OobeUpdateRuleTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -21,6 +21,7 @@ public class SessionAnchorSourceTests
[InlineData("SystemTimelineWatcher")]
[InlineData("WindowsUpdateWatcher")]
[InlineData("ServicingWatcher")]
[InlineData("ShellCoreTracker")] // the update page, read back an hour on an agent's first run
public void Backfill_sources_are_not_anchor_eligible(string source)
{
Assert.False(Constants.EventSources.IsSessionAnchorEligible(source));
@@ -30,6 +31,7 @@ public class SessionAnchorSourceTests
[InlineData("ImeLogTracker")]
[InlineData("Agent")]
[InlineData("MdmRebootPolicyWatcher")]
[InlineData("EspAndHelloTracker")] // the ESP and Hello signals from the same Shell-Core log
[InlineData("windowsupdatewatcher")] // ordinal: a differently cased source is not the watcher
[InlineData(null)] // legacy row without a source
public void Activity_sources_are_anchor_eligible(string? source)
@@ -92,6 +92,8 @@ public sealed class SharedManifestParityTests
["appInstallSources"] = AppInstallSources.All,
// Event sources whose rows never set a session's start; the portal's session duration applies them.
["sessionAnchorIneligibleSources"] = Constants.EventSources.SessionAnchorIneligible,
// How an OOBE quality update ended (OsUpdateSpan.Outcome in the time attribution).
["osUpdateOutcomes"] = OsUpdateOutcomes.All,
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
@@ -6,10 +6,12 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Golden fixtures for the OOBE quality update in the time attribution (AttributionVersion 4,
/// D-314): <c>os_update</c> from the update page start whose visit holds update evidence to the
/// last update evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c>
/// until the user is back. Sequences follow the field sessions of 2026-10-02 (synthetic times, no
/// customer data). EVERY fixture asserts the exact-partition invariant.
/// D-314; v5): <c>os_update</c> from the update page start whose visit holds update evidence — or,
/// when the agent missed the start, from the update's first evidence — to the last update evidence
/// before the user is back, then — after a restart — <c>awaiting_sign_in</c> until the user is back;
/// the latest outcome evidence names the outcome. Sequences follow the field sessions of
/// 2026-10-02..05 (synthetic times, no customer data). EVERY fixture asserts the exact-partition
/// invariant.
/// </summary>
public class TimeAttributionOsUpdateTests
{
@@ -131,7 +133,7 @@ public class TimeAttributionOsUpdateTests
var b = TimeAttributionCalculator.Compute(Input(LongUpdateWithSecondVisit(), At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(4, b.AttributionVersion);
Assert.Equal(TimeAttributionCalculator.CurrentVersion, b.AttributionVersion);
Assert.Equal(120, Total(b, TimeAttributionSegments.DevicePrep));
Assert.Equal(18 * 60 + 5, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start
Assert.Equal(100 * 60 + 31, Total(b, TimeAttributionSegments.OsUpdate)); // page start → second visit ends
@@ -144,7 +146,11 @@ public class TimeAttributionOsUpdateTests
Assert.Equal(At(120, 36), update.EndUtc);
Assert.Equal(100 * 60 + 31, update.Seconds);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
Assert.Empty(update.NotInstalledKbs);
Assert.Equal(3, update.RebootCount);
// downloadInstallFailureHelper and a start timeout after "restart required" are no failure:
// the package reached Installed after the restarts (field shape e4ecd6f8).
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
// The update's restarts sit in its own segment.
Assert.Equal(3, b.RebootSpans.Count);
@@ -363,6 +369,348 @@ public class TimeAttributionOsUpdateTests
Assert.Equal(23 * 60, Total(b, TimeAttributionSegments.EspApps)); // the hole until the first step stays the phase's
}
// ── the agent started after the page (v5) ───────────────────────────────
/// <summary>
/// Field shape 6a7d241d: the agent's first run began after the device ESP and after the page
/// start — it saw neither (a first run reads no Shell-Core history), only the update's later
/// page names and the servicing steps the watcher backfills an hour.
/// </summary>
private static List<EnrollmentEvent> LateAgentUpdate()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
// A package the OOBE itself finished during the ESP: no part of the update.
Servicing(At(5), "initiating", package: "KB5128942", targetState: "Staged"),
Servicing(At(6), "state_reached", package: "KB5128942", targetState: "Staged"),
Servicing(At(6, 5), "initiating", package: "KB5128942"),
Servicing(At(7), "state_reached", package: "KB5128942"),
// The update's servicing stack, staged before the agent ran.
Servicing(At(20), "initiating", package: "KB5124007", targetState: "Staged"),
Servicing(At(20, 5), "state_reached", package: "KB5124007", targetState: "Staged"),
// The agent runs from here on.
PageName(At(27, 40), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Servicing(At(32, 25), "initiating", package: "KB5124007"),
Servicing(At(33, 22), "state_reached", package: "KB5124007"),
Servicing(At(34), "initiating", package: "KB5129195", targetState: "Staged"),
Servicing(At(36), "state_reached", package: "KB5129195", targetState: "Staged"),
Servicing(At(36, 10), "initiating", package: "KB5054156", targetState: "Staged"),
Servicing(At(38), "state_reached", package: "KB5054156", targetState: "Staged"),
Servicing(At(38, 10), "initiating", package: "KB5129195"),
Servicing(At(38, 20), "initiating", package: "KB5054156"),
Servicing(At(43, 5), "reboot_required", package: "KB5129195"),
Servicing(At(43, 6), "reboot_required", package: "KB5054156"),
PageName(At(43, 10), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired"),
PageName(At(43, 12), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageName(At(43, 30), "SdxWebAppCloudNDUP_rebootCountdown_starting"),
PageStart(At(43, 35), "RebootNDUP"),
RebootDetected(At(46), lastBoot: At(45)),
Servicing(At(50), "state_reached", package: "KB5129195"),
Evt(At(52), "agent_shutting_down"),
// The second restart; the backfilled servicing step is the first event after it.
Servicing(At(59, 17), "state_reached", package: "KB5054156"),
RebootDetected(At(59, 30), lastBoot: At(58)),
Evt(At(62, 10), "desktop_arrived"),
Evt(At(63), "enrollment_complete"),
};
return events;
}
[Fact]
public void LateAgent_UpdateBeginsAtTheFirstStepOfAPackageItAlsoServiced()
{
var b = TimeAttributionCalculator.Compute(Input(LateAgentUpdate(), At(63), 63 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
// Not the OOBE's own package at 5 min: it has no step after the update's first name.
Assert.Equal(At(20), update.StartUtc);
Assert.Equal(At(59, 17), update.EndUtc); // the last step, where the second restart ends
Assert.Equal(39 * 60 + 17, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Equal(2 * 60 + 53, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → desktop
Assert.Equal(18 * 60, Total(b, TimeAttributionSegments.EspApps));
Assert.Equal(new[] { "KB5124007", "KB5129195", "KB5054156" }, update.Kbs);
Assert.Empty(update.NotInstalledKbs);
Assert.Equal(2, update.RebootCount);
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
}
[Fact]
public void LateAgent_WithoutItsNames_StaysTheAppsPhase()
{
// Without its names the update is invisible to a window without an observed exit (v4 saw
// it so even with them): the servicing steps alone never begin an update there.
var withoutNames = LateAgentUpdate().Where(e => e.EventType != "oobe_update_page").ToList();
var b = TimeAttributionCalculator.Compute(Input(withoutNames, At(63), 63 * 60))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal((62 * 60 + 10) - 2 * 60, Total(b, TimeAttributionSegments.EspApps));
}
[Fact]
public void LateAgent_NamesWithoutServicing_BeginAtTheFirstName()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
PageName(At(24), "SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress40"),
PageName(At(29), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageStop(At(29, 20), "OobeNDUP", "success"),
Evt(At(31), "desktop_arrived"),
Evt(At(32), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(Input(events, At(32), 32 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(24), update.StartUtc);
Assert.Equal(At(29, 20), update.EndUtc);
Assert.Equal(0, update.RebootCount);
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn)); // no restart: nobody was signed out
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
Assert.Empty(update.Kbs);
}
[Fact]
public void MissedFirstVisit_BeginsAtItsNames_NotAtTheSecondVisit()
{
// The agent missed visit 1 (it updated and restarted) and saw the confirmation visit,
// which holds no evidence of its own: v4 found no update at all.
var events = DeviceEsp();
events.RemoveAll(e => e.EventType == "esp_exiting");
events.AddRange(new[]
{
Servicing(At(21), "initiating", targetState: "Staged"),
PageName(At(23), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Servicing(At(30), "state_reached", targetState: "Staged"),
Servicing(At(30, 10), "initiating"),
Servicing(At(36), "reboot_required"),
PageName(At(36, 5), "SdxWebAppCloudNDUP_rebootCountdown_starting"),
PageStart(At(36, 10), "RebootNDUP"),
RebootDetected(At(38), lastBoot: At(37)),
Servicing(At(39), "state_reached"),
PageStart(At(39, 10), "OobeNDUP"),
PageStop(At(39, 30), "OobeNDUP", "success"),
Evt(At(41), "desktop_arrived"),
Evt(At(42), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(42), 42 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(21), update.StartUtc); // the package's first step, before the name
Assert.Equal(At(39, 30), update.EndUtc);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
Assert.Equal(1, update.RebootCount);
Assert.Equal(90, Total(b, TimeAttributionSegments.AwaitingSignIn));
}
[Fact]
public void EspExitAfterTheNames_DoesNotBoundTheBegin()
{
// An exit the agent saw after the update's names belongs to a later handoff; the begin
// comes from the packages the update serviced, as without an exit.
var events = LateAgentUpdate();
events.Add(Evt(At(44), "esp_exiting"));
var b = TimeAttributionCalculator.Compute(Input(events, At(63), 63 * 60))!;
AssertExactPartition(b);
Assert.Equal(At(20), Assert.Single(b.OsUpdates).StartUtc);
}
[Fact]
public void EspExitBeforeTheNames_BoundsTheBeginAsInV4()
{
// A known exit before the names: the first servicing step after it begins the update —
// the KB staged during the ESP stays out, whatever the update did with it later.
var events = LateAgentUpdate();
events.Add(Evt(At(25), "esp_exiting"));
var b = TimeAttributionCalculator.Compute(Input(events, At(63), 63 * 60))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(27, 40), update.StartUtc); // no step between the exit and the first name
Assert.Equal(new[] { "KB5124007", "KB5129195", "KB5054156" }, update.Kbs);
}
[Fact]
public void NamesAfterTheUserIsBack_AreNoUpdate()
{
var events = WithAccountPhase(new[]
{
PageName(At(28, 30), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
PageName(At(29), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
}
[Fact]
public void WhiteGlove_PartOneExit_DoesNotBeginAnUpdateInPartTwo()
{
// v4 took the session's first exit: part 1's let a servicing step in part 2 begin an
// update. Part 2 here has no exit, no page and no update name — no update.
var part1End = At(30);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(20);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(At(25), "esp_exiting"),
Evt(part1End, "whiteglove_part1_complete"),
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.DeviceSetup),
Evt(resumedAt.AddMinutes(1), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Servicing(resumedAt.AddMinutes(5), "initiating", targetState: "Staged"),
Servicing(resumedAt.AddMinutes(8), "state_reached", targetState: "Staged"),
Evt(resumedAt.AddMinutes(15), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(resumedAt.AddMinutes(16), "hello_wizard_started"),
Evt(resumedAt.AddMinutes(19), "desktop_arrived"),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 30 * 60 + 20 * 60, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
}
// ── outcome ─────────────────────────────────────────────────────────────
[Fact]
public void FailedDownload_IsFailed_AndTheStagedKbIsNotInstalled()
{
// Field shape e7551b82: only the servicing stack staged; the download failed after 25
// minutes, the page restarted the device, and the second visit found nothing to offer.
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
PageName(At(21, 42), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
Servicing(At(22), "initiating", package: "KB5124007", targetState: "Staged"),
Servicing(At(26), "state_reached", package: "KB5124007", targetState: "Staged"),
PageName(At(45, 10), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError"),
PageName(At(45, 12), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure"),
PageName(At(45, 13), "SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
PageStop(At(45, 20), "OobeNDUP", "fail"),
PageStart(At(45, 20), "RebootNDUP"),
RebootDetected(At(46, 30), lastBoot: At(45, 50)),
PageStart(At(46, 51), "OobeNDUP"),
PageName(At(46, 52), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "1"),
PageName(At(47, 10), "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop(At(47, 13), "OobeNDUP", "cancel"),
Evt(At(48, 15), "desktop_arrived"),
Evt(At(50), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(50), 3000))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(20, 2), update.StartUtc);
Assert.Equal(At(47, 13), update.EndUtc);
Assert.Equal(OsUpdateOutcomes.Failed, update.Outcome);
Assert.Empty(update.Kbs);
Assert.Equal(new[] { "KB5124007" }, update.NotInstalledKbs);
Assert.Equal(1, update.RebootCount);
}
[Fact]
public void FailureThenSkip_IsSkipped()
{
// Field shape cb1992b8: visit 1 failed; visit 2 downloaded again, and someone selected
// Skip before the install began.
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(21), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
PageName(At(30), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError"),
PageName(At(30, 2), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure"),
PageStop(At(30, 10), "OobeNDUP", "fail"),
PageStart(At(30, 10), "RebootNDUP"),
RebootDetected(At(31, 30), lastBoot: At(30, 40)),
PageStart(At(32), "OobeNDUP"),
PageName(At(32, 1), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "1"),
PageName(At(33), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
PageName(At(40), "SdxWebAppCloudNDUP_manageProgressBars_waitingForInstallStartError"),
PageName(At(41), "CloudNDUPSkipDownloadInstallButtonClicked", "0"),
PageStop(At(41, 5), "OobeNDUP", "cancel"),
Evt(At(43), "desktop_arrived"),
Evt(At(44), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(44), 2640))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(OsUpdateOutcomes.Skipped, update.Outcome);
Assert.Equal(At(41, 5), update.EndUtc);
}
[Fact]
public void FailureRetriedAndInstalled_IsInstalled()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(21), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
PageName(At(25), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError"),
PageName(At(26), "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
Servicing(At(27), "initiating"),
Servicing(At(30), "state_reached"),
PageName(At(30, 5), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageStop(At(30, 10), "OobeNDUP", "success"),
Evt(At(32), "desktop_arrived"),
Evt(At(33), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(33), 1980))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(OsUpdateOutcomes.Installed, update.Outcome);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
}
[Fact]
public void FailedServicingStep_IsFailed()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
Servicing(At(22), "initiating"),
Servicing(At(28), "failed"),
PageStop(At(28, 30), "OobeNDUP", "cancel"),
Evt(At(30), "desktop_arrived"),
Evt(At(31), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(31), 1860))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(OsUpdateOutcomes.Failed, update.Outcome);
Assert.Empty(update.Kbs);
Assert.Equal(new[] { "KB5129195" }, update.NotInstalledKbs);
}
[Theory]
[InlineData("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted", true)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded", true)]
@@ -448,6 +796,9 @@ public class TimeAttributionOsUpdateTests
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(25), update.EndUtc); // the step after the failure is not attributed
Assert.Equal(4 * 60 + 58, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Empty(update.Kbs); // only staged before the bound
Assert.Equal(new[] { "KB5129195" }, update.NotInstalledKbs);
Assert.Equal(OsUpdateOutcomes.Unknown, update.Outcome);
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.True(b.UnattributedSeconds >= 10 * 60); // the post-failure tail stays unattributed
}
@@ -53,7 +53,12 @@ public class TimeAttributionPersistenceAndAggregationTests
},
OsUpdates = new List<OsUpdateSpan>
{
new() { StartUtc = T0.AddMinutes(9), EndUtc = T0.AddMinutes(19), Seconds = 600, Kbs = new List<string> { "KB5129195", "KB5054156" }, RebootCount = 2 },
new()
{
StartUtc = T0.AddMinutes(9), EndUtc = T0.AddMinutes(19), Seconds = 600,
Kbs = new List<string> { "KB5129195", "KB5054156" }, NotInstalledKbs = new List<string> { "KB5124007" },
Outcome = OsUpdateOutcomes.Failed, RebootCount = 2,
},
},
BlockingApps = new List<BlockingAppInterval>
{
@@ -98,6 +103,8 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Equal(T0.AddMinutes(19), update.EndUtc);
Assert.Equal(600, update.Seconds);
Assert.Equal(new[] { "KB5129195", "KB5054156" }, update.Kbs);
Assert.Equal(new[] { "KB5124007" }, update.NotInstalledKbs);
Assert.Equal(OsUpdateOutcomes.Failed, update.Outcome);
Assert.Equal(2, update.RebootCount);
var app = Assert.Single(mapped.BlockingApps);
@@ -117,6 +124,21 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Empty(TableStorageService.MapToSessionTimeBreakdown(entity).OsUpdates);
}
[Fact]
public void BreakdownEntity_OsUpdateWrittenBeforeTheOutcome_MapsToUnknown()
{
// v4 spans carry no Outcome/NotInstalledKbs until the sweep recomputes them.
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(
new SessionTimeBreakdown { TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600 });
entity["OsUpdatesJson"] =
"[{\"StartUtc\":\"2026-10-02T14:00:00Z\",\"EndUtc\":\"2026-10-02T14:10:00Z\",\"Seconds\":600,\"Kbs\":[\"KB5129195\"],\"RebootCount\":1}]";
var update = Assert.Single(TableStorageService.MapToSessionTimeBreakdown(entity).OsUpdates);
Assert.Equal(OsUpdateOutcomes.Unknown, update.Outcome);
Assert.Empty(update.NotInstalledKbs);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
}
[Fact]
public void BreakdownEntity_OccupancyTriState_AbsentColumnMapsToNull_NeverZero()
{
@@ -67,10 +67,11 @@ public sealed class TimeAttributionInput
/// <c>system_reboot_detected</c> event is detection-time-stamped by the next run and never used
/// as the reboot moment (audit Q7). Reboots are a cross-cutting annotation, not a segment.</item>
/// <item><b>The OOBE quality update takes its time from the phase segments</b> (v4, D-314):
/// <c>os_update</c> from the start of the update page that installed an update to the last
/// update evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c>
/// until the first evidence of the user. Without update evidence (the page cancelled or found
/// nothing) the partition is the phase partition unchanged.</item>
/// <c>os_update</c> from the start of the update page that worked on an update — or, when the
/// agent started after the page, from the update's first evidence (v5) — to the last update
/// evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c> until the
/// first evidence of the user. Without update evidence (the page cancelled or found nothing) the
/// partition is the phase partition unchanged. The latest outcome evidence names the outcome.</item>
/// </list>
/// </summary>
public static class TimeAttributionCalculator
@@ -82,7 +83,9 @@ public static class TimeAttributionCalculator
// annotations mirroring RebootSpans; the maintenance sweep recomputes older rows.
// v4: os_update + awaiting_sign_in — the OOBE quality update and the wait for the user after
// its restart, cut out of the phase segment they overlap (D-314), with OsUpdates naming the KBs.
public const int CurrentVersion = 4;
// v5: an update whose page start the agent missed begins at its first evidence; the device-ESP
// exit is the window's own; OsUpdates carry the outcome and split installed from not installed KBs.
public const int CurrentVersion = 5;
/// <summary>
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
@@ -217,6 +220,8 @@ public static class TimeAttributionCalculator
EndUtc = u.End,
Seconds = ClampToWindows(u.Start, u.End, windows)?.Seconds ?? 0,
Kbs = u.Kbs,
NotInstalledKbs = u.NotInstalledKbs,
Outcome = u.Outcome,
RebootCount = u.RebootCount,
})
.ToList(),
@@ -774,28 +779,55 @@ public static class TimeAttributionCalculator
public DateTime? AwaitingSignInEnd;
public List<string> Kbs = new();
public List<string> NotInstalledKbs = new();
public string Outcome = OsUpdateOutcomes.Unknown;
public int RebootCount;
}
private readonly struct PageRecord
{
public PageRecord(DateTime at, string cxhEvent, string? page, string? name)
public PageRecord(DateTime at, string cxhEvent, string? page, string? name, string? result)
{
At = at;
CxhEvent = cxhEvent;
Page = page;
Name = name;
Result = result;
}
public DateTime At { get; }
public string CxhEvent { get; }
public string? Page { get; }
public string? Name { get; }
public string? Result { get; }
public bool IsStartOf(string page) => CxhEvent == "page_started" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
public bool IsStopOf(string page) => CxhEvent == "page_stopped" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
}
/// <summary>One CBS servicing step (<c>windows_update_servicing</c>) of an OS package.</summary>
private readonly struct ServicingStep
{
public ServicingStep(DateTime at, string? package, string? step, string? targetState)
{
At = at;
Package = package;
Step = step;
TargetState = targetState;
}
public DateTime At { get; }
public string? Package { get; }
public string? Step { get; }
public string? TargetState { get; }
public bool ReachedInstalled =>
string.Equals(Step, "state_reached", StringComparison.OrdinalIgnoreCase) &&
string.Equals(TargetState, "Installed", StringComparison.OrdinalIgnoreCase);
public bool Failed => string.Equals(Step, "failed", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Page names that only an update in progress writes (field data 2026-10-02). Every visit
/// logs init names such as <c>initialize_NDUPInstallCanceledInOptOut</c> and
@@ -818,21 +850,48 @@ public static class TimeAttributionCalculator
/// The update page's own record of a download or an install — evidence of an update next to
/// the servicing steps (an agent caps the page names per run, so this is the weaker of the two).
/// </summary>
internal static bool IsUpdateActivityName(string? name) =>
internal static bool IsUpdateActivityName(string? name) => HasMarker(name, UpdateActivityMarkers);
// Outcome evidence on the update page (field data 2026-10-03/05). Only the explicit failures
// count: an update that installed fine can still log downloadInstallFailureHelper and a start
// timeout after its "restart required" (session shape e4ecd6f8).
private static readonly string[] UpdateSucceededMarkers =
{
"installSucceededRebootRequired", // also the _lcu twin
"commitExpeditionDownloadInstallAsyncSucceeded",
};
private static readonly string[] UpdateFailedMarkers =
{
"downloadFailedError",
"installFailedError",
"commitExpeditionDownloadInstallAsyncFailure",
};
private static readonly string[] UpdateSkippedMarkers =
{
"SkipDownloadInstallButtonClicked", // someone selected Skip on the page
};
private static bool HasMarker(string? name, string[] markers) =>
name != null &&
UpdateActivityMarkers.Any(marker => name.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0);
markers.Any(marker => name.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0);
/// <summary>
/// At most one update per observation window. <b>Begin:</b> the start of the update page
/// (<see cref="UpdatePage"/>) whose visit holds update evidence — a servicing step or an
/// At most one update per observation window. <b>Begin:</b> update-activity names the agent
/// saw before the first observed start of the update page (<see cref="UpdatePage"/>) — it
/// started after the visit they belong to — begin at the earliest of the first such name and
/// the update's first servicing step (see <see cref="FindUpdateBegin"/>). Otherwise the start
/// of the update page whose visit holds update evidence — a servicing step or an
/// update-activity name before the visit ends (its stop, the <see cref="UpdateRestartPage"/>
/// start or the next visit). Only when no page start was observed in the window: the first
/// servicing step after the device-ESP exit. Never after the user is back. <b>Bound:</b> the first evidence of the
/// user (user ESP apps, desktop, Hello wizard, a Complete declaration), the end of
/// attribution (Failed) or the window end. <b>End:</b> the latest update evidence before the
/// bound — page record, servicing step, or the end of a restart that began in the interval.
/// With a restart in the interval the user is signed out: <c>awaiting_sign_in</c> runs from
/// the end to the bound.
/// start or the next visit). Only when no page start and no such name was observed in the
/// window: the first servicing step after the window's device-ESP exit. Never after the user
/// is back. <b>Bound:</b> the first evidence of the user (user ESP apps, desktop, Hello
/// wizard, a Complete declaration), the end of attribution (Failed) or the window end.
/// <b>End:</b> the latest update evidence before the bound — page record, servicing step, or
/// the end of a restart that began in the interval. With a restart in the interval the user
/// is signed out: <c>awaiting_sign_in</c> runs from the end to the bound. <b>Outcome:</b> the
/// latest outcome evidence in [begin, bound) — see <see cref="ResolveOutcome"/>.
/// </summary>
private static List<OsUpdate> BuildOsUpdates(
List<EnrollmentEvent> events, List<Window> windows, List<Anchor> anchors,
@@ -841,7 +900,7 @@ public static class TimeAttributionCalculator
var result = new List<OsUpdate>();
var pages = new List<PageRecord>();
var servicing = new List<(DateTime At, string? Package)>();
var servicing = new List<ServicingStep>();
foreach (var evt in events)
{
if (evt.EventType == Constants.EventTypes.OobeUpdatePage && evt.Data != null)
@@ -849,11 +908,14 @@ public static class TimeAttributionCalculator
pages.Add(new PageRecord(evt.Timestamp,
DataString(evt.Data, "cxhEvent") ?? string.Empty,
DataString(evt.Data, "page"),
DataString(evt.Data, "name")));
DataString(evt.Data, "name"),
DataString(evt.Data, "result")));
}
else if (evt.EventType == Constants.EventTypes.WindowsUpdateServicing)
{
servicing.Add((evt.Timestamp, evt.Data != null ? DataString(evt.Data, "package") : null));
servicing.Add(evt.Data != null
? new ServicingStep(evt.Timestamp, DataString(evt.Data, "package"), DataString(evt.Data, "step"), DataString(evt.Data, "targetState"))
: new ServicingStep(evt.Timestamp, null, null, null));
}
}
if (servicing.Count == 0 && !pages.Any(p => IsUpdateActivityName(p.Name))) return result;
@@ -874,11 +936,17 @@ public static class TimeAttributionCalculator
.OrderBy(t => t)
.ToList();
var attributionEnd = anchors.FirstOrDefault(a => a.Bucket == null)?.Ts;
var deviceEspExit = events.FirstOrDefault(e => e.EventType == Constants.EventTypes.EspExiting)?.Timestamp;
// esp_exiting fires for the Device→Account handoff AND the final exit and is never
// replayed; each window picks its own (a WhiteGlove part-1 exit never bounds part 2).
var espExits = events
.Where(e => e.EventType == Constants.EventTypes.EspExiting)
.Select(e => e.Timestamp)
.OrderBy(t => t)
.ToList();
foreach (var window in windows)
{
var begin = FindUpdateBegin(window, pages, evidence, servicing, deviceEspExit, userBack);
var begin = FindUpdateBegin(window, pages, evidence, servicing, espExits, userBack, attributionEnd);
if (!begin.HasValue) continue;
var bound = window.End;
@@ -896,13 +964,22 @@ public static class TimeAttributionCalculator
{
if (page.At > update.End && page.At < bound) update.End = page.At;
}
foreach (var (at, package) in servicing)
var servicedKbs = new List<string>();
var installedKbs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var step in servicing)
{
if (at < begin.Value || at >= bound) continue;
if (at > update.End) update.End = at;
var kb = ExtractKb(package);
if (kb != null && !update.Kbs.Contains(kb)) update.Kbs.Add(kb);
if (step.At < begin.Value || step.At >= bound) continue;
if (step.At > update.End) update.End = step.At;
var kb = ExtractKb(step.Package);
if (kb == null) continue;
if (!servicedKbs.Contains(kb)) servicedKbs.Add(kb);
if (step.ReachedInstalled) installedKbs.Add(kb);
}
// A KB counts as installed only once its package reached "Installed"; a failed
// update can leave packages staged.
update.Kbs = servicedKbs.Where(installedKbs.Contains).ToList();
update.NotInstalledKbs = servicedKbs.Where(kb => !installedKbs.Contains(kb)).ToList();
update.Outcome = ResolveOutcome(pages, servicing, begin.Value, bound);
foreach (var (gapStart, gapEnd) in rebootGaps)
{
if (gapStart < begin.Value || gapStart >= bound) continue;
@@ -923,11 +1000,86 @@ public static class TimeAttributionCalculator
}
private static DateTime? FindUpdateBegin(
Window window, List<PageRecord> pages, List<DateTime> evidence,
List<(DateTime At, string? Package)> servicing, DateTime? deviceEspExit, List<DateTime> userBack)
Window window, List<PageRecord> pages, List<DateTime> evidence, List<ServicingStep> servicing,
List<DateTime> espExits, List<DateTime> userBack, DateTime? attributionEnd)
{
// An update after the user is back runs beside the enrollment, not instead of it.
bool UserBackBy(DateTime t) => userBack.Any(u => u > window.Start && u <= t);
bool InWindow(DateTime t) => t >= window.Start && t < window.End;
// This window's device-ESP exit before the given moment, if one was observed.
DateTime? EspExitBefore(DateTime before)
{
foreach (var exit in espExits)
{
if (exit < window.Start) continue;
if (exit >= window.End || exit >= before) break;
return exit;
}
return null;
}
DateTime? firstPageStart = null;
foreach (var page in pages)
{
if (!page.IsStartOf(UpdatePage) || !InWindow(page.At)) continue;
firstPageStart = page.At;
break;
}
// Update-activity names before the first observed page start: the agent started after
// the visit they belong to (a first run reads no Shell-Core history), so its start is
// missing. Evaluated first — their visit lies before any observed one.
DateTime? firstOrphanName = null;
foreach (var page in pages)
{
if (firstPageStart.HasValue && page.At >= firstPageStart.Value) break;
if (!InWindow(page.At) || !IsUpdateActivityName(page.Name) || UserBackBy(page.At)) continue;
firstOrphanName = page.At;
break;
}
if (firstOrphanName.HasValue)
{
var firstName = firstOrphanName.Value;
DateTime? firstStep = null;
var exitBefore = EspExitBefore(firstName);
if (exitBefore.HasValue)
{
// A known exit bounds it as in v4: the first servicing step after the exit.
foreach (var step in servicing)
{
if (step.At < exitBefore.Value || step.At < window.Start) continue;
if (step.At < firstName) firstStep = step.At;
break;
}
}
else
{
// No exit observed: the earliest step of a package this update also serviced at or
// after its first name — the servicing watcher backfills an hour, so the update's
// early staging is there; a package finished before (the OOBE's own) is not.
var limit = window.End;
if (attributionEnd.HasValue && attributionEnd.Value < limit) limit = attributionEnd.Value;
foreach (var u in userBack)
{
if (u <= firstName) continue;
if (u < limit) limit = u;
break;
}
var members = new HashSet<string>(
servicing.Where(s => s.At >= firstName && s.At < limit && !string.IsNullOrEmpty(s.Package)).Select(s => s.Package!),
StringComparer.OrdinalIgnoreCase);
foreach (var step in servicing)
{
if (step.At < window.Start) continue;
if (step.At >= firstName) break;
if (string.IsNullOrEmpty(step.Package) || !members.Contains(step.Package!)) continue;
firstStep = step.At;
break;
}
}
return firstStep.HasValue && firstStep.Value < firstName ? firstStep.Value : firstName;
}
var pageStartSeen = false;
for (var i = 0; i < pages.Count; i++)
@@ -963,17 +1115,62 @@ public static class TimeAttributionCalculator
// a servicing step beside it is some other update.
if (pageStartSeen) return null;
// Fallback: the page start was not observed (the agent started after it). The first
// servicing step after the device-ESP exit — without the download before it.
if (!deviceEspExit.HasValue) return null;
foreach (var (at, _) in servicing)
// Fallback: neither the page start nor an update name was observed (the agent started
// after the page and saw only CBS). The first servicing step after this window's
// device-ESP exit before the user is back — without the download before it.
DateTime? deviceEspExit = null;
foreach (var exit in espExits)
{
if (at < deviceEspExit.Value || at < window.Start || at >= window.End) continue;
return UserBackBy(at) ? null : at;
if (exit < window.Start) continue;
if (exit >= window.End || UserBackBy(exit)) break;
deviceEspExit = exit;
break;
}
if (!deviceEspExit.HasValue) return null;
foreach (var step in servicing)
{
if (step.At < deviceEspExit.Value || step.At < window.Start || step.At >= window.End) continue;
return UserBackBy(step.At) ? null : step.At;
}
return null;
}
/// <summary>
/// How the update in [begin, bound) ended: the latest outcome evidence decides — a package
/// reaching "Installed" or the page's install success (installed), an explicit download or
/// install failure, a failed servicing step or a page stop with result "fail" (failed), or the
/// page's Skip button (skipped). A later attempt overrides an earlier one (a failure retried
/// and installed is installed; a failure followed by Skip is skipped). No evidence: unknown.
/// </summary>
private static string ResolveOutcome(List<PageRecord> pages, List<ServicingStep> servicing, DateTime begin, DateTime bound)
{
DateTime? latest = null;
var outcome = OsUpdateOutcomes.Unknown;
void Consider(DateTime at, string kind)
{
if (latest.HasValue && at < latest.Value) return;
latest = at;
outcome = kind;
}
foreach (var page in pages)
{
if (page.At < begin || page.At >= bound) continue;
if (HasMarker(page.Name, UpdateSucceededMarkers)) Consider(page.At, OsUpdateOutcomes.Installed);
else if (HasMarker(page.Name, UpdateFailedMarkers)) Consider(page.At, OsUpdateOutcomes.Failed);
else if (HasMarker(page.Name, UpdateSkippedMarkers)) Consider(page.At, OsUpdateOutcomes.Skipped);
else if (page.IsStopOf(UpdatePage) && string.Equals(page.Result, "fail", StringComparison.OrdinalIgnoreCase))
Consider(page.At, OsUpdateOutcomes.Failed);
}
foreach (var step in servicing)
{
if (step.At < begin || step.At >= bound) continue;
if (step.ReachedInstalled) Consider(step.At, OsUpdateOutcomes.Installed);
else if (step.Failed) Consider(step.At, OsUpdateOutcomes.Failed);
}
return outcome;
}
private static string? ExtractKb(string? package)
{
if (string.IsNullOrEmpty(package)) return null;
@@ -3060,8 +3060,12 @@ export interface OsUpdateSpan {
endUtc: string;
/** In-window seconds of the interval. */
seconds: number;
/** KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one. */
/** KB numbers of the packages that reached "Installed" in the interval ("KB5129195"), in order of first appearance; empty when none did or no package named one. */
kbs: string[];
/** KB numbers of the packages Windows serviced in the interval without reaching "Installed" (for example only staged before a failed download). */
notInstalledKbs: string[];
/** How the update ended — one of OsUpdateOutcomes; the latest outcome evidence in the interval decides. */
outcome: string;
/** Restarts that began inside the interval. */
rebootCount: number;
}
@@ -2060,8 +2060,8 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
'time breakdown — wall-clock partition into device_prep / esp_apps / os_update / awaiting_sign_in / ' +
'identity_hello / user_esp / desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact ' +
'against the session\'s authoritative duration; the WhiteGlove pause is excluded by design; os_update = the ' +
'OOBE quality update, its KBs and restarts in osUpdates; awaiting_sign_in = after that update\'s restart ' +
'until the user is back), the ESP-blocking app install ' +
'OOBE quality update, its outcome, installed and not installed KBs and restarts in osUpdates; ' +
'awaiting_sign_in = after that update\'s restart until the user is back), the ESP-blocking app install ' +
'intervals with critical-path occupancy (an app\'s seconds = its ACTIVE install time summed across all ' +
'IME passes/attempts — deliberately broader than get_app_install_metrics\' final-attempt duration, ' +
'because a failed attempt and an evaluation pass still occupied the critical path), reboot outage ' +
@@ -658,7 +658,7 @@ namespace AutopilotMonitor.Shared
// update page (CXID OobeNDUP / RebootNDUP) and its ExpeditedUpdate_* event names, plus the
// registry state behind it (NDUP keys and both ESP "Install Windows quality updates"
// policy paths). Bounded per agent run.
public const string OobeUpdatePage = "oobe_update_page"; // Shell-Core 62404/62405 of an NDUP page, 62406/62407 update-page names — Data.cxhEvent+page+result+name(+value: boolean/integer/HRESULT only)
public const string OobeUpdatePage = "oobe_update_page"; // Shell-Core 62404/62405 of an NDUP page, 62406/62407 update-page names — Data.cxhEvent+page+result+name(+value: boolean/integer/HRESULT only); cxhEvent names_capped = the run's later names were not sent
public const string OobeUpdateState = "oobe_update_state"; // NDUP + ESP-policy registry values at agent start/stop and at the update page — Data.moment + one entry per key alias; only when values exist and changed
// Corroboration snapshots (gather rules, config-delivered). Secondary evidence that an
// update landed during enrollment even when the watcher's backfill window missed it.
@@ -930,19 +930,28 @@ namespace AutopilotMonitor.Shared
/// <summary>CBS servicing steps from the Setup log.</summary>
public const string ServicingWatcher = "ServicingWatcher";
/// <summary>
/// The OOBE update page telemetry from the Shell-Core log (<c>oobe_update_page</c>,
/// <c>oobe_update_state</c>). The ESP and Hello signals read from the same log carry
/// the source "EspAndHelloTracker".
/// </summary>
public const string ShellCoreTracker = "ShellCoreTracker";
/// <summary>
/// Sources whose rows never set a session's start. Their watchers backfill the Windows
/// event logs with the original event time — clock steps and sleep up to 24 h, update
/// and servicing activity up to 60 min before the agent started — so their earliest row
/// is environment observation from before the enrollment, not its first step. Every
/// place that derives the start from events applies this list: backend ingest and the
/// Events-table probe, and the portal's session duration via shared-manifests.json.
/// and servicing activity and the update page up to 60 min before the agent started —
/// so their earliest row is environment observation from before the enrollment, not
/// its first step. Every place that derives the start from events applies this list:
/// backend ingest and the Events-table probe, and the portal's session duration via
/// shared-manifests.json.
/// </summary>
public static readonly string[] SessionAnchorIneligible =
{
SystemTimelineWatcher,
WindowsUpdateWatcher,
ServicingWatcher,
ShellCoreTracker,
};
/// <summary>
@@ -18,10 +18,11 @@ namespace AutopilotMonitor.Shared.Models
public const string EspApps = "esp_apps";
/// <summary>
/// The OOBE quality update (D-310): from the start of the update page that installed an
/// update to the last update evidence before the user is back — download, install, the
/// restarts and the servicing after them. Takes the time from the phase segment it
/// overlaps; one <see cref="OsUpdateSpan"/> per interval names the packages.
/// The OOBE quality update (D-310): from the start of the update page that worked on an
/// update — or, when the agent started after the page, from the update's first evidence —
/// to the last update evidence before the user is back: download, install, the restarts
/// and the servicing after them. Takes the time from the phase segment it overlaps; one
/// <see cref="OsUpdateSpan"/> per interval names the packages and the outcome.
/// </summary>
public const string OsUpdate = "os_update";
@@ -196,13 +197,40 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>In-window seconds of the interval.</summary>
public int Seconds { get; set; }
/// <summary>KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one.</summary>
/// <summary>KB numbers of the packages that reached "Installed" in the interval ("KB5129195"), in order of first appearance; empty when none did or no package named one.</summary>
public List<string> Kbs { get; set; } = new List<string>();
/// <summary>KB numbers of the packages Windows serviced in the interval without reaching "Installed" (for example only staged before a failed download).</summary>
public List<string> NotInstalledKbs { get; set; } = new List<string>();
/// <summary>How the update ended — one of <see cref="OsUpdateOutcomes"/>; the latest outcome evidence in the interval decides.</summary>
public string Outcome { get; set; } = OsUpdateOutcomes.Unknown;
/// <summary>Restarts that began inside the interval.</summary>
public int RebootCount { get; set; }
}
/// <summary>
/// Outcome of an OOBE quality update (<see cref="OsUpdateSpan.Outcome"/>), decided by the
/// latest outcome evidence in its interval. Exported to the portal via shared-manifests.json.
/// </summary>
public static class OsUpdateOutcomes
{
/// <summary>A package reached "Installed", or the update page reported the install as succeeded.</summary>
public const string Installed = "installed";
/// <summary>The update page or servicing reported the download or install as failed.</summary>
public const string Failed = "failed";
/// <summary>Someone selected Skip on the update page; the update was not installed.</summary>
public const string Skipped = "skipped";
/// <summary>No outcome evidence before the interval ended (for example the session ended first).</summary>
public const string Unknown = "unknown";
public static readonly string[] All = { Installed, Failed, Skipped, Unknown };
}
/// <summary>
/// Per-session enrollment time attribution (F1, insights spec) — an exact partition of the
/// session's authoritative wall clock into named segments plus an explicit unattributed
@@ -2,6 +2,7 @@
import React from "react";
import type { SessionTimeBreakdown } from "@/utils/wire-types.generated";
import { osUpdateDetail } from "./timeAttributionLogic";
/**
* F1 time attribution (insights spec §F1): renders the PRE-COMPUTED breakdown row of a
@@ -85,15 +86,9 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
}
totals.set("unattributed", breakdown.unattributedSeconds);
// What the OOBE quality update installed and how often it restarted — named on the
// Windows Update slice (the server computed it; the client only joins the lists).
const osUpdates = breakdown.osUpdates ?? [];
const updateKbs = Array.from(new Set(osUpdates.flatMap(u => u.kbs ?? [])));
const updateRestarts = osUpdates.reduce((sum, u) => sum + (u.rebootCount ?? 0), 0);
const updateDetail = [
updateKbs.join(", "),
updateRestarts > 0 ? `${updateRestarts} restart${updateRestarts !== 1 ? "s" : ""}` : "",
].filter(Boolean).join(" · ");
// How the OOBE quality update ended, what it installed and how often it restarted — named on
// the Windows Update slice.
const updateDetail = osUpdateDetail(breakdown.osUpdates ?? []);
const parts = SEGMENT_META
.map(meta => ({
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
import { osUpdateDetail } from "../timeAttributionLogic";
describe("osUpdateDetail", () => {
it("names the outcome, the installed KBs and the restarts of an installed update", () => {
expect(osUpdateDetail([
{ outcome: "installed", kbs: ["KB5124007", "KB5129195", "KB5054156"], notInstalledKbs: [], rebootCount: 2 },
])).toBe("installed · KB5124007, KB5129195, KB5054156 · 2 restarts");
});
it("names a failed update's staged KB as not installed", () => {
// Field shape e7551b82: only the servicing stack was staged before the download failed.
expect(osUpdateDetail([
{ outcome: "failed", kbs: [], notInstalledKbs: ["KB5124007"], rebootCount: 1 },
])).toBe("failed · not installed: KB5124007 · 1 restart");
});
it("names a skipped update", () => {
expect(osUpdateDetail([{ outcome: "skipped", kbs: [], notInstalledKbs: [], rebootCount: 0 }])).toBe("skipped");
});
it("names no outcome for an unknown one or a row written before the outcome existed", () => {
expect(osUpdateDetail([{ outcome: "unknown", kbs: ["KB5129195"], rebootCount: 0 }])).toBe("KB5129195");
expect(osUpdateDetail([{ kbs: ["KB5129195"], rebootCount: 3 }])).toBe("KB5129195 · 3 restarts");
});
it("is empty without updates", () => {
expect(osUpdateDetail([])).toBe("");
});
it("labels every outcome the backend can send", () => {
// A new backend outcome must get its words here, not fall through as unnamed.
for (const outcome of SHARED_MANIFEST.osUpdateOutcomes) {
const detail = osUpdateDetail([{ outcome, kbs: [], notInstalledKbs: [], rebootCount: 0 }]);
expect(detail).toBe(outcome === "unknown" ? "" : outcome);
}
});
});
@@ -0,0 +1,37 @@
import type { OsUpdateSpan } from "@/utils/wire-types.generated";
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
/** How an OOBE quality update ended (backend `OsUpdateOutcomes`). */
export type OsUpdateOutcome = (typeof SHARED_MANIFEST.osUpdateOutcomes)[number];
/** The words the Windows Update slice names an outcome with; "unknown" names nothing. */
const OUTCOME_LABELS: Record<OsUpdateOutcome, string | null> = {
installed: "installed",
failed: "failed",
skipped: "skipped",
unknown: null,
};
/**
* What the OOBE quality update did, for the Windows Update slice: the outcome, the KBs that
* reached "Installed", the ones it serviced without installing, and its restarts — e.g.
* "failed · not installed: KB5124007 · 1 restart". The server computed every part; this only
* joins the lists. Rows written before the outcome existed carry none and name no outcome.
*/
export function osUpdateDetail(osUpdates: ReadonlyArray<Partial<OsUpdateSpan>>): string {
const outcomes = distinct(osUpdates.map(u => OUTCOME_LABELS[u.outcome as OsUpdateOutcome] ?? null));
const installed = distinct(osUpdates.flatMap(u => u.kbs ?? []));
const notInstalled = distinct(osUpdates.flatMap(u => u.notInstalledKbs ?? []));
const restarts = osUpdates.reduce((sum, u) => sum + (u.rebootCount ?? 0), 0);
return [
outcomes.join(", "),
installed.join(", "),
notInstalled.length > 0 ? `not installed: ${notInstalled.join(", ")}` : "",
restarts > 0 ? `${restarts} restart${restarts !== 1 ? "s" : ""}` : "",
].filter(Boolean).join(" · ");
}
function distinct(values: ReadonlyArray<string | null>): string[] {
return Array.from(new Set(values.filter((v): v is string => !!v)));
}
@@ -95,13 +95,15 @@ describe("enrollmentWindowOf", () => {
expect(w).toEqual({ startMs: Date.parse("2026-09-09T06:32:46Z"), endMs: Date.parse("2026-09-09T06:40:00Z") });
});
it("ignores update and servicing rows backfilled from before the agent started", () => {
// D-315: the update watchers deliver rows with their event-log time up to 60 min before the agent.
it("ignores update, servicing and update-page rows backfilled from before the agent started", () => {
// D-315: the update watchers deliver rows with their event-log time up to 60 min before the agent;
// so does the update page telemetry on an agent's first run.
const w = enrollmentWindowOf([
activity(1, "2026-10-03T08:51:58Z", "windows_update_started", "WindowsUpdateWatcher"),
activity(2, "2026-10-03T08:52:37Z", "windows_update_servicing", "ServicingWatcher"),
activity(3, "2026-10-03T08:56:46Z", "ime_agent_version", "ImeLogTracker"),
activity(4, "2026-10-03T10:08:09Z", "enrollment_complete", "DecisionEngine"),
activity(3, "2026-10-03T08:53:10Z", "oobe_update_page", "ShellCoreTracker"),
activity(4, "2026-10-03T08:56:46Z", "ime_agent_version", "ImeLogTracker"),
activity(5, "2026-10-03T10:08:09Z", "enrollment_complete", "DecisionEngine"),
]);
expect(w).toEqual({ startMs: Date.parse("2026-10-03T08:56:46Z"), endMs: Date.parse("2026-10-03T10:08:09Z") });
});
+1 -1
View File
@@ -12,7 +12,7 @@ export interface StandbyWindow {
/**
* The enrollment window as the session page measures its duration: first activity event →
* `enrollment_complete`, or the last activity event while no verdict exists. Backfilled
* environment rows (clock steps, sleep, update and servicing activity) are excluded, as in the
* environment rows (clock steps, sleep, update and servicing activity, the update page) are excluded, as in the
* backend's session anchor — they carry Windows event-log timestamps from before the agent
* started and would drag the start into pre-enrollment time. Null when the session has no
* activity events.
@@ -506,7 +506,14 @@ export const SHARED_MANIFEST = {
"sessionAnchorIneligibleSources": [
"SystemTimelineWatcher",
"WindowsUpdateWatcher",
"ServicingWatcher"
"ServicingWatcher",
"ShellCoreTracker"
],
"osUpdateOutcomes": [
"installed",
"failed",
"skipped",
"unknown"
],
"ruleSubmissionKinds": [
"gather",
@@ -502,7 +502,14 @@
"sessionAnchorIneligibleSources": [
"SystemTimelineWatcher",
"WindowsUpdateWatcher",
"ServicingWatcher"
"ServicingWatcher",
"ShellCoreTracker"
],
"osUpdateOutcomes": [
"installed",
"failed",
"skipped",
"unknown"
],
"ruleSubmissionKinds": [
"gather",
@@ -14410,7 +14417,26 @@
"name": "string"
}
},
"doc": "KB numbers of the packages Windows serviced in the interval (\"KB5129195\"), in order of first appearance; empty when no package named one."
"doc": "KB numbers of the packages that reached \"Installed\" in the interval (\"KB5129195\"), in order of first appearance; empty when none did or no package named one."
},
{
"name": "notInstalledKbs",
"type": {
"kind": "array",
"element": {
"kind": "primitive",
"name": "string"
}
},
"doc": "KB numbers of the packages Windows serviced in the interval without reaching \"Installed\" (for example only staged before a failed download)."
},
{
"name": "outcome",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "How the update ended — one of OsUpdateOutcomes; the latest outcome evidence in the interval decides."
},
{
"name": "rebootCount",
@@ -3060,8 +3060,12 @@ export interface OsUpdateSpan {
endUtc: string;
/** In-window seconds of the interval. */
seconds: number;
/** KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one. */
/** KB numbers of the packages that reached "Installed" in the interval ("KB5129195"), in order of first appearance; empty when none did or no package named one. */
kbs: string[];
/** KB numbers of the packages Windows serviced in the interval without reaching "Installed" (for example only staged before a failed download). */
notInstalledKbs: string[];
/** How the update ended — one of OsUpdateOutcomes; the latest outcome evidence in the interval decides. */
outcome: string;
/** Restarts that began inside the interval. */
rebootCount: number;
}