mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb79c7711c | |||
| 249e39bc68 | |||
| 6bd9139869 | |||
| 73373a2c9e | |||
| 6d607c9045 | |||
| 3af94d9dcd | |||
| 0d5e2572aa | |||
| 793e907e60 | |||
| 8e8bb66371 | |||
| 7e1389cf32 | |||
| 3a89319472 | |||
| b37e6f975c | |||
| cacbd24667 | |||
| a157d98a40 | |||
| 7fa50d09c1 | |||
| 94afc02e62 | |||
| 5e4c5e80be | |||
| b535973531 | |||
| 7e0c578a59 | |||
| 1257e0d195 | |||
| 98f8114d90 | |||
| 7b3230d507 | |||
| 87b7a20223 | |||
| 4c19be1de4 | |||
| 93de130cd1 | |||
| b8b95c7e76 | |||
| e57e081cec | |||
| 3a60186824 | |||
| c35595503f | |||
| 838da4cc8a | |||
| a9937e0f5d | |||
| 1dc2cc9a54 | |||
| dd7ae299a8 | |||
| d96d10ccec | |||
| 457fff65f5 | |||
| dc45694611 | |||
| 7569061983 | |||
| 0f5e36db94 | |||
| e1de5a8971 | |||
| a2d93e884c | |||
| 425229deee | |||
| 8675e7ee06 | |||
| e857e99436 | |||
| 6435d5f290 | |||
| 445ce4e75b | |||
| 4e54ffe4b4 | |||
| 65d45a616e | |||
| 4aa1f07fbb | |||
| ede616444a | |||
| 61a359ee54 | |||
| babc89c776 | |||
| a2f448cad0 | |||
| f15e115dea | |||
| 977c5fb447 | |||
| 27fdd19568 | |||
| 72f7adcb3d | |||
| 06cf1c4c12 | |||
| e612c69178 | |||
| 829a841d9b | |||
| 10eedded13 | |||
| a9f6ccd86d | |||
| 7ba67df1a1 | |||
| e214e8dc1a | |||
| f8d0ccd2f5 | |||
| c9701301ea | |||
| ba2a390a83 | |||
| dbd7d2ac6f | |||
| 8f24876682 | |||
| 2d5ee887d7 | |||
| 81c14bb3ed | |||
| 3a0bd74b12 | |||
| 50e7590060 | |||
| 61e53a2e9b | |||
| 7e7b6f8086 | |||
| 2e20476559 | |||
| 990641b19e | |||
| 3ca2f3b499 | |||
| b9fa760404 | |||
| ab61839685 | |||
| 34fd40c3ad | |||
| a0eb1b9e35 | |||
| 9d677db2a8 | |||
| c728a0bd41 | |||
| 64336c0c89 | |||
| 59c944618b | |||
| 3c25172dfb | |||
| eda61eaade | |||
| 04b1a7c6e7 | |||
| 3b59dab2df | |||
| 389426f8f2 | |||
| e9dba11b96 | |||
| 828cbb5cd0 | |||
| 93a42ba5e4 | |||
| aaacc6ecb2 | |||
| bbc7a1d8b2 | |||
| 5f00124a86 | |||
| d43cfe2bb3 | |||
| 78cc038426 | |||
| ff8b847dc0 | |||
| aab7ae12ff | |||
| 935f0e8c71 | |||
| 541dc32ffd | |||
| 022cc0354b | |||
| 3aa8b95676 | |||
| ec945a93d2 | |||
| b36cf8ff36 | |||
| c6ee7808f1 | |||
| b61d7023bc | |||
| e1b03de5b3 | |||
| 25bac34acb | |||
| c0be389a95 | |||
| 91f9d656ae | |||
| b1e7077d0a | |||
| d8c84f41c1 | |||
| 12b51bbc24 | |||
| a369ff99cd | |||
| 7a131af74d | |||
| e750cfe9dd | |||
| 3629728d93 | |||
| e521fd5cf4 | |||
| 0ed1e6f542 | |||
| 522911c5a7 | |||
| 0bfe1e327b | |||
| 2cf7216be5 | |||
| 587adc5ddf | |||
| 84bf2bc2fd | |||
| dcf1b1c227 | |||
| 6ebdfa41af | |||
| a2c4949473 | |||
| 81f4a55d88 | |||
| 4d3d3c56f4 | |||
| 41898d745a | |||
| c05ea4d63b | |||
| 68eb2da283 | |||
| ab0f27b5ab | |||
| 32d8151185 | |||
| f3749ccc16 | |||
| ca26cb4f7c | |||
| 8ceab7a064 | |||
| ae85dc325c | |||
| aeb2be6d64 | |||
| 0474223290 | |||
| 1bdb71be59 | |||
| ccf4f2e054 | |||
| d192a8e0fa | |||
| 06d92460ed | |||
| fc3900429c | |||
| 0ffcb2fde2 | |||
| a3089753d3 | |||
| 26cbc897c5 | |||
| c88629dcf2 | |||
| 517041a51c | |||
| eeaeb9ea7e | |||
| 3983402983 | |||
| 4deb5e6a04 | |||
| 5734eb576e | |||
| 7fbfea8f4e | |||
| e5f955354d | |||
| 65651dd599 | |||
| d0ab3eee40 | |||
| 2de8f5c4e6 | |||
| fe07f5cef0 | |||
| 57775d6ba2 | |||
| 91b702e239 | |||
| e12da0c99b | |||
| 3ac647a680 | |||
| ecf6e8c5a8 | |||
| f26721c0f7 | |||
| 789926fcad | |||
| 01e76a6ff7 | |||
| daa6df6bab | |||
| 98fd173fb8 | |||
| 06626a4095 | |||
| 22a8c7a51d | |||
| 72136f0273 | |||
| 9c96881242 | |||
| 6c6014940e | |||
| c60c0bbede | |||
| c4280e823c | |||
| 6dc2506fc5 | |||
| 88cd29d3b9 | |||
| f4d99b43f4 | |||
| 6c2b431aec | |||
| 383d0694fe | |||
| 792b7d3b0e | |||
| c48f95b88c | |||
| 4c413337ca | |||
| 8277d70915 | |||
| e1b3ec25fd | |||
| 321ae63c63 | |||
| 842db1603a | |||
| c770bbffcf | |||
| 3fbdc8ef62 | |||
| 35cea4fe64 | |||
| ade6c4948f | |||
| 83da522dd7 | |||
| c15394df87 | |||
| 810a6f1f2f | |||
| 019ac3959e | |||
| 9236aba46e | |||
| fda52666d6 | |||
| dcec6a1863 | |||
| 041626494e | |||
| fd32a90d57 | |||
| 90b8243894 | |||
| 66d25f57db | |||
| 84a0424789 | |||
| 596788cc26 | |||
| aec2d249e1 | |||
| a63f126b88 | |||
| d94a91780c | |||
| 0b83fe2390 | |||
| 75c0c60717 | |||
| 29e7aa675a | |||
| c28d64fe27 | |||
| b2723168fd | |||
| d05d8ef302 | |||
| 2c48968ec6 | |||
| 2f8ad82b0a | |||
| 4b74995e79 | |||
| 5d8d9e8d40 | |||
| 25bd2c175e | |||
| 830edb98e7 | |||
| 1ebc2675b1 | |||
| 283c2ed214 | |||
| 13dd6723da | |||
| cb6d80a76e | |||
| 024c90d68f |
+1
-1
Submodule .claude/commands updated: 0766ebfef6...ba49ac60ca
@@ -0,0 +1,367 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Publishes the customer-facing PowerShell scripts behind download.autopilotmonitor.com/agent/.
|
||||
|
||||
.DESCRIPTION
|
||||
Single owner of the script-publishing chain. Called from two places:
|
||||
|
||||
* publish-scripts.yml -- on every push to main that touches the sources,
|
||||
so a script fix reaches customers without waiting
|
||||
for an agent release.
|
||||
* build-agent.yml -- on a publish_as_stable run, so a release always
|
||||
lands a consistent set (ZIP + manifest + scripts).
|
||||
|
||||
Both callers run the same code. The scripts on the download alias are the live
|
||||
copy the WDP bootstrap MSI (Invoke-BootstrapDownload.ps1) downloads on every
|
||||
enrolling device, so "current" is a correctness property, not cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the bootstrap source.
|
||||
2. Render the -Dev variant by literal substitution of the two URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
3. Version-bump guard: if the published bootstrap differs from the source
|
||||
but carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
5. Reconcile the two version oracles: version.json.bootstrapVersion
|
||||
(read-modify-write under If-Match, so a concurrent agent release cannot
|
||||
lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
6. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS for the eu storage account with write permission. Leading '?' tolerated.
|
||||
|
||||
.PARAMETER LegacySasToken
|
||||
Container SAS for the legacy account. Optional -- a mirror failure warns, never fails.
|
||||
|
||||
.PARAMETER TableSasToken
|
||||
Table SAS for AdminConfiguration. Optional -- omitted or 'XXX' skips the oracle update.
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
Repository root. Defaults to the parent of .github/scripts.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Run every check and render, upload nothing.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -SasToken $env:SAS -TableSasToken $env:TABLE_SAS
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$SasToken,
|
||||
[string]$LegacySasToken,
|
||||
[string]$TableSasToken,
|
||||
[string]$RepoRoot,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
}
|
||||
if (-not $DryRun -and [string]::IsNullOrWhiteSpace($SasToken)) {
|
||||
throw 'SasToken is required unless -DryRun is used.'
|
||||
}
|
||||
|
||||
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
|
||||
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
|
||||
$AliasUrl = 'https://download.autopilotmonitor.com/agent'
|
||||
$TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
|
||||
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
|
||||
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
|
||||
$DevBlob = 'Install-AutopilotMonitor-Dev.ps1'
|
||||
$ScriptContentType = 'text/plain; charset=utf-8'
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
return [int]$ErrorRecord.Exception.Response.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-Sha256 {
|
||||
param([byte[]]$Bytes)
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
# Raw bytes of a published blob, or $null when it does not exist yet.
|
||||
function Get-PublishedBytes {
|
||||
param([string]$Url)
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $Url -Method Get -UseBasicParsing
|
||||
} catch {
|
||||
if ((Get-HttpStatus $_) -eq 404) { return $null }
|
||||
throw
|
||||
}
|
||||
# Comma operator: PowerShell unrolls an array returned from a function into the output
|
||||
# stream and reassembles it as Object[], which would silently defeat every [byte[]] use
|
||||
# downstream. ',' wraps it so the byte[] survives the return.
|
||||
if ($resp.Content -is [byte[]]) { return ,$resp.Content }
|
||||
return ,[System.Text.Encoding]::UTF8.GetBytes([string]$resp.Content)
|
||||
}
|
||||
|
||||
# Deterministic publish form: CRLF, UTF-8 without BOM. Git normalises line endings on
|
||||
# checkout (a Windows runner yields CRLF, a core.autocrlf=false clone yields LF), so
|
||||
# publishing the raw working-copy bytes would emit different blobs for identical source
|
||||
# and make every byte comparison -- guard and verify alike -- environment-dependent.
|
||||
# CRLF is also what PS 5.1 on the device expects and what is published today.
|
||||
function Get-PublishBytes {
|
||||
param([string]$Text)
|
||||
$normalised = $Text.Replace("`r`n", "`n").Replace("`n", "`r`n")
|
||||
# ',' keeps this a byte[]: without it PowerShell unrolls the array into the output stream
|
||||
# and the caller receives Object[]. Invoke-RestMethod then serialises an Object[] body as
|
||||
# space-separated decimals instead of raw bytes -- a silent corruption of the upload.
|
||||
return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised)
|
||||
}
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
|
||||
# the state a republish is meant to repair, and a guard that blocks the repair is worse
|
||||
# than no guard.
|
||||
if ($Optional) { return $null }
|
||||
throw "Could not parse ScriptVersion from $Origin"
|
||||
}
|
||||
|
||||
# What the script actually DOES, with comments and layout removed: the PowerShell token
|
||||
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
|
||||
# a behaviour change (must be versioned -- consumers act on the version) apart from a
|
||||
# comment fix or reflow (nobody needs to know). Tokenising rather than regex-stripping
|
||||
# keeps '#' inside strings from being mistaken for a comment.
|
||||
function Get-CodeFingerprint {
|
||||
param([string]$Text, [string]$Origin)
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseInput($Text, [ref]$tokens, [ref]$errors) | Out-Null
|
||||
if ($errors -and $errors.Count -gt 0) {
|
||||
throw "Parse error in ${Origin}: $($errors[0])"
|
||||
}
|
||||
$ignored = @('Comment', 'NewLine', 'LineContinuation', 'EndOfInput')
|
||||
return (($tokens | Where-Object { $ignored -notcontains $_.Kind.ToString() } | ForEach-Object { $_.Text }) -join "`n")
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 1. source
|
||||
$sourceContent = Get-Content $BootstrapSource -Raw
|
||||
$sourceBytes = Get-PublishBytes $sourceContent
|
||||
$scriptVersion = Get-BootstrapScriptVersion -Content $sourceContent -Origin $BootstrapSource
|
||||
Write-Host "Bootstrap script version: $scriptVersion"
|
||||
|
||||
# ------------------------------------------------------------------ 2. dev render
|
||||
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableManLiteral = '$VersionJsonName = "version.json"'
|
||||
$devManLiteral = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
if ($sourceContent.IndexOf($stableUrlLiteral) -lt 0 -or $sourceContent.IndexOf($stableManLiteral) -lt 0) {
|
||||
throw "Bootstrap dev-render: anchor literal missing in $BootstrapSource"
|
||||
}
|
||||
$devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace($stableManLiteral, $devManLiteral)
|
||||
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guard
|
||||
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BootstrapBlob"
|
||||
if ($null -eq $publishedBytes) {
|
||||
Write-Host 'No published bootstrap yet -- version-bump guard skipped (first publish)'
|
||||
} elseif ((Get-Sha256 $publishedBytes) -eq (Get-Sha256 $sourceBytes)) {
|
||||
Write-Host 'Published bootstrap is already byte-identical to the source'
|
||||
} else {
|
||||
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
|
||||
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional
|
||||
|
||||
if ($null -eq $publishedVersion) {
|
||||
Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it."
|
||||
} elseif ($publishedVersion -ne $scriptVersion) {
|
||||
Write-Host "Version bump: $publishedVersion -> $scriptVersion"
|
||||
} elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq
|
||||
(Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) {
|
||||
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
|
||||
Write-Host "::warning::$BootstrapBlob changed in comments or formatting only; publishing under the unchanged version $scriptVersion."
|
||||
} else {
|
||||
throw ("Version-bump guard: $BootstrapBlob changed behaviour but ScriptVersion is still $scriptVersion. " +
|
||||
"Bump it in $BootstrapSource -- the docs badge, version.json.bootstrapVersion and the " +
|
||||
'portal outdated-script hint all read that value, and customers decide from it whether ' +
|
||||
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 4. upload
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
$extraSources = @(
|
||||
@{ Path = 'scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1'; BlobName = 'Test-ShouldBootstrapAgent.ps1' }
|
||||
# Customer-side Graph add-on grant script -- docs and admin UI link it directly.
|
||||
@{ Path = 'scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1'; BlobName = 'Grant-AutopilotMonitorAddOn.ps1' }
|
||||
)
|
||||
# Uninstall-AutopilotMonitor.ps1 is deliberately NOT published: nothing links it, and
|
||||
# an unauthenticated uninstall script on the public download host is not a feature.
|
||||
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Bytes = (Get-PublishBytes $devContent) })
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
throw "Publish source missing: $extraPath"
|
||||
}
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Bytes = (Get-PublishBytes (Get-Content $extraPath -Raw)) })
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
$writeSas = if ($SasToken) { $SasToken.TrimStart('?') } else { '' }
|
||||
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
# parameter coercion repairs it everywhere EXCEPT the wire.
|
||||
[byte[]]$bytes = $item.Bytes
|
||||
$item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force
|
||||
|
||||
if ($DryRun) {
|
||||
# Doubles as a drift check: says per blob whether the alias already serves this source.
|
||||
$served = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
|
||||
$state = if ($null -eq $served) { 'MISSING on the alias' }
|
||||
elseif ((Get-Sha256 $served) -eq $item.Sha256) { 'already current' }
|
||||
else { 'STALE on the alias' }
|
||||
Write-Host " [dry-run] $($item.BlobName) ($($bytes.Length) bytes, sha256 $($item.Sha256)) -- $state"
|
||||
continue
|
||||
}
|
||||
|
||||
# no-cache on every blob: they all rotate in place and the download alias fronts them
|
||||
# with Front Door. Route caching is disabled there, but a stale script paired with a
|
||||
# fresh manifest would fail the bootstrap SHA check -- this keeps that class of bug
|
||||
# impossible even if caching is ever re-enabled.
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = $ScriptContentType; 'x-ms-blob-cache-control' = 'no-cache' }
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)?$writeSas" -Method Put -Headers $headers -Body $bytes | Out-Null
|
||||
|
||||
# Read straight back from the blob (authoritative, no CDN in the way) before touching the
|
||||
# mirror or the next file. The alias verification at the end would catch a bad body too,
|
||||
# but only after every blob was already overwritten -- these are live customer downloads.
|
||||
$written = Get-PublishedBytes "$ContainerUrl/$($item.BlobName)"
|
||||
if ($null -eq $written -or (Get-Sha256 $written) -ne $item.Sha256) {
|
||||
$writtenLength = if ($null -eq $written) { 'missing' } else { "$($written.Length) bytes" }
|
||||
throw ("Upload of $($item.BlobName) did not round-trip: sent $($bytes.Length) bytes, " +
|
||||
"blob now holds $writtenLength. Publishing stopped before the mirror and the " +
|
||||
'remaining files, so no further blob was touched.')
|
||||
}
|
||||
Write-Host " uploaded $($item.BlobName) ($($bytes.Length) bytes, round-trip verified)"
|
||||
|
||||
if ($legacySas) {
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$LegacyContainerUrl/$($item.BlobName)?$legacySas" -Method Put -Headers $headers -Body $bytes | Out-Null
|
||||
Write-Host " mirrored $($item.BlobName) to legacy storage"
|
||||
} catch {
|
||||
Write-Host "::warning::legacy mirror failed for $($item.BlobName) -- $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 5. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
if (-not $DryRun) {
|
||||
try {
|
||||
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
|
||||
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
|
||||
[System.Text.Encoding]::UTF8.GetString($manifestResp.Content)
|
||||
} else { [string]$manifestResp.Content }
|
||||
$manifest = $manifestRaw | ConvertFrom-Json
|
||||
|
||||
if ($manifest.bootstrapVersion -eq $scriptVersion) {
|
||||
Write-Host "version.json already reports bootstrapVersion $scriptVersion"
|
||||
} else {
|
||||
$etag = '"' + ([string]($manifestResp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
$manifest | Add-Member -NotePropertyName 'bootstrapVersion' -NotePropertyValue $scriptVersion -Force
|
||||
$body = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Compress))
|
||||
$manifestHeaders = @{
|
||||
'x-ms-blob-type' = 'BlockBlob'
|
||||
'Content-Type' = 'application/json'
|
||||
'x-ms-blob-cache-control' = 'no-cache'
|
||||
'If-Match' = $etag
|
||||
}
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/version.json?$writeSas" -Method Put -Headers $manifestHeaders -Body $body | Out-Null
|
||||
Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)"
|
||||
|
||||
if ($legacySas) {
|
||||
try {
|
||||
$legacyHeaders = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = 'application/json'; 'x-ms-blob-cache-control' = 'no-cache' }
|
||||
Invoke-RestMethod -Uri "$LegacyContainerUrl/version.json?$legacySas" -Method Put -Headers $legacyHeaders -Body $body | Out-Null
|
||||
} catch {
|
||||
Write-Host "::warning::legacy mirror failed for version.json -- $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ((Get-HttpStatus $_) -eq 412) {
|
||||
throw 'version.json changed while publishing (concurrent agent release). Re-run this workflow.'
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
$tableSas = if ($TableSasToken) { $TableSasToken.TrimStart('?') } else { '' }
|
||||
if ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} elseif ([string]::IsNullOrEmpty($tableSas) -or $tableSas -eq 'XXX') {
|
||||
Write-Host 'SKIPPED: AdminConfiguration update -- set AZURE_TABLE_ADMIN_SAS_TOKEN to enable the portal version oracle'
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
'Content-Type' = 'application/json'
|
||||
'Accept' = 'application/json;odata=nometadata'
|
||||
'If-Match' = '*'
|
||||
}
|
||||
$tableBody = @{ LatestBootstrapV2ScriptVersion = $scriptVersion } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$TableUrl`?$tableSas" -Method Merge -Headers $tableHeaders -Body $tableBody | Out-Null
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
|
||||
for ($attempt = 1; $attempt -le 6 -and $pending.Count -gt 0; $attempt++) {
|
||||
if ($attempt -gt 1) { Start-Sleep -Seconds 20 }
|
||||
foreach ($item in @($pending)) {
|
||||
$servedBytes = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
|
||||
if ($null -ne $servedBytes -and (Get-Sha256 $servedBytes) -eq $item.Sha256) {
|
||||
Write-Host " verified $($item.BlobName) via $AliasUrl"
|
||||
$pending.Remove($item)
|
||||
}
|
||||
}
|
||||
if ($pending.Count -gt 0) {
|
||||
Write-Host " attempt $attempt : $($pending.Count) blob(s) not current on the alias yet"
|
||||
}
|
||||
}
|
||||
|
||||
if ($pending.Count -gt 0) {
|
||||
$names = ($pending | ForEach-Object { $_.BlobName }) -join ', '
|
||||
throw ("Alias still serves stale content for: $names. The blobs were written, so this is a Front Door " +
|
||||
'cache issue -- purge with: az afd endpoint purge --resource-group rg-autopilotmonitor-prd-gwc ' +
|
||||
"--profile-name autopilotmonitor-fd --endpoint-name apm-download --content-paths '/agent/*'")
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
'',
|
||||
"Bootstrap script version: **$scriptVersion**",
|
||||
''
|
||||
) + ($publishSet | ForEach-Object { "- $($_.BlobName) -- sha256 $($_.Sha256)" })
|
||||
($summary -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||||
}
|
||||
|
||||
Write-Host 'Done.'
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Bootstrap script gates
|
||||
|
||||
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
|
||||
# once and both callers run the identical checks:
|
||||
# * pester.yml -- pull requests (reports, does not block the merge button)
|
||||
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
|
||||
# download alias. Devices execute these scripts as SYSTEM
|
||||
# during enrollment, so a red suite must never ship.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
|
||||
# platform scripts, so guard/relax logic is verified without resetting a device.
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
|
||||
# The publish chain renders the -Dev variant by literal substitution and refuses to
|
||||
# ship when an anchor moved. Running it dry here surfaces that (and any drift against
|
||||
# the alias) on the PR instead of on the push that would have published.
|
||||
- name: Publish dry run (dev-render anchors + version-bump guard + drift)
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Publish-BootstrapScripts.ps1 -DryRun
|
||||
@@ -33,6 +33,13 @@ permissions:
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
# Shared with publish-scripts.yml: both write version.json (this workflow the agent fields,
|
||||
# the script publisher bootstrapVersion under If-Match). Serialising them keeps a script push
|
||||
# from colliding with a release. Never cancel -- a half-published set is worse than a queue.
|
||||
concurrency:
|
||||
group: agent-blob-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LINE_MAJOR: 2
|
||||
AGENT_PROJECT: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
|
||||
@@ -46,8 +53,6 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
STABLE_BOOTSTRAP: Install-AutopilotMonitor.ps1
|
||||
STABLE_BOOTSTRAP_DEV: Install-AutopilotMonitor-Dev.ps1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -181,6 +186,8 @@ jobs:
|
||||
# commit/buildUtc are the same two fields the backend and MCP manifests carry —
|
||||
# additive, and the bootstrap reads this file by field name, so older installers
|
||||
# ignore them. sha256/bootstrapVersion stay: those are the customer contract.
|
||||
# bootstrapVersion is seeded here so the published manifest is never briefly
|
||||
# missing it; Publish-BootstrapScripts.ps1 owns and re-asserts the field afterwards.
|
||||
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
|
||||
$manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force
|
||||
$manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
|
||||
@@ -190,29 +197,12 @@ jobs:
|
||||
$manifest | ConvertTo-Json -Compress | Set-Content $env:MANIFEST_NAME -Encoding UTF8
|
||||
Write-Host "$env:MANIFEST_NAME content: $(Get-Content $env:MANIFEST_NAME)"
|
||||
|
||||
# Bootstrap = single generic source. Stable variant = source 1:1.
|
||||
# Dev variant = literal-string substitution of the two URL/manifest defaults.
|
||||
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
|
||||
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
|
||||
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableMan = '$VersionJsonName = "version.json"'
|
||||
$devMan = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
Copy-Item $env:BOOTSTRAP_SOURCE $env:STABLE_BOOTSTRAP -Force
|
||||
$content = Get-Content $env:BOOTSTRAP_SOURCE -Raw
|
||||
if ($content.IndexOf($stableUrl) -lt 0 -or $content.IndexOf($stableMan) -lt 0) {
|
||||
Write-Error "Bootstrap dev-render: anchor literal missing in $env:BOOTSTRAP_SOURCE"
|
||||
exit 1
|
||||
}
|
||||
$devContent = $content.Replace($stableUrl, $devUrl).Replace($stableMan, $devMan)
|
||||
[System.IO.File]::WriteAllText("$env:STABLE_BOOTSTRAP_DEV", $devContent, (New-Object System.Text.UTF8Encoding $false))
|
||||
}
|
||||
# The customer-facing scripts (stable + dev render, test script, grant script) are
|
||||
# published by .github/scripts/Publish-BootstrapScripts.ps1 -- see the upload step.
|
||||
|
||||
"versioned_zip_release=$versionedZip" >> $env:GITHUB_OUTPUT
|
||||
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
|
||||
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
|
||||
"bootstrap_version=$bootstrapVersion" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
@@ -264,16 +254,21 @@ jobs:
|
||||
Write-Host "PUBLISH AS STABLE -- writing V2 into stable namespace (customer-facing)" -ForegroundColor Magenta
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
|
||||
Send-Blob -Local $env:STABLE_BOOTSTRAP -BlobName $env:STABLE_BOOTSTRAP -ContentType "text/plain; charset=utf-8"
|
||||
Send-Blob -Local $env:STABLE_BOOTSTRAP_DEV -BlobName $env:STABLE_BOOTSTRAP_DEV -ContentType "text/plain; charset=utf-8"
|
||||
|
||||
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
|
||||
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}'
|
||||
}
|
||||
|
||||
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
|
||||
# must never point at a build that is not actually in the stable namespace.
|
||||
# LatestBootstrapV2ScriptVersion is NOT set here: it belongs to the script publisher
|
||||
# above, which also owns it on script-only pushes. One writer per field.
|
||||
$tableSasToken = "${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}".TrimStart('?')
|
||||
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
|
||||
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
|
||||
@@ -283,22 +278,18 @@ jobs:
|
||||
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
|
||||
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
|
||||
$agentVersion = "${{ steps.version.outputs.version }}"
|
||||
$bootstrapVersion = "${{ steps.artifact.outputs.bootstrap_version }}"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
|
||||
$tableBody = @{
|
||||
LatestAgentV2Version = $agentVersion
|
||||
LatestAgentV2Sha256 = $sha256
|
||||
LatestAgentV2ExeSha256 = $exeSha256
|
||||
}
|
||||
if ($bootstrapVersion) {
|
||||
$tableBody["LatestBootstrapV2ScriptVersion"] = $bootstrapVersion
|
||||
}
|
||||
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
|
||||
"Content-Type" = "application/json"
|
||||
"Accept" = "application/json;odata=nometadata"
|
||||
"If-Match" = "*"
|
||||
} -Body ($tableBody | ConvertTo-Json)
|
||||
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256, bootstrapVersion=$bootstrapVersion"
|
||||
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
|
||||
}
|
||||
|
||||
# Sigstore keyless provenance: binds the ZIP digest to this workflow run (repo, ref,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Build & Publish Bootstrap MSI
|
||||
|
||||
# Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see
|
||||
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI carries no bootstrap
|
||||
# logic (only the runner script that downloads the CURRENT server-hosted
|
||||
# Install-AutopilotMonitor.ps1), so it is rebuilt rarely: only when the runner script
|
||||
# or the WiX package changes. Version lives in Package.wxs — bump it there before
|
||||
# dispatching so Intune LOB update detection sees a new ProductVersion.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_blob:
|
||||
description: 'Upload to blob (customer-facing via download.autopilotmonitor.com/agent/)'
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
|
||||
RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1
|
||||
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
name: Build Bootstrap MSI
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# The runner executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
- name: Read MSI version from Package.wxs
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') {
|
||||
Write-Error "Could not parse Version from Package.wxs"
|
||||
exit 1
|
||||
}
|
||||
$version = $Matches[1]
|
||||
Write-Host "MSI version (from Package.wxs): $version"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version "5.*"
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
$sha256 = (Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
Write-Host "SHA-256: $sha256"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
|
||||
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
|
||||
# stable name rotates in place behind the download alias. No legacy mirror — the
|
||||
# MSI channel never existed on the legacy account.
|
||||
function Send-Blob {
|
||||
param([string]$Local, [string]$BlobName)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Local)
|
||||
$headers = @{
|
||||
"x-ms-blob-type" = "BlockBlob"
|
||||
"Content-Type" = "application/octet-stream"
|
||||
"x-ms-blob-cache-control" = "no-cache"
|
||||
}
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
|
||||
$md5.Dispose()
|
||||
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1KB)) KB)..."
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName`?$sasToken" -Method Put -Headers $headers -Body $bytes
|
||||
Write-Host " uploaded $BlobName"
|
||||
}
|
||||
|
||||
# Versioned copy for provenance/rollback + stable customer-facing name.
|
||||
Send-Blob -Local $env:STABLE_MSI -BlobName "AutopilotMonitor-Bootstrap-v$version.msi"
|
||||
Send-Blob -Local $env:STABLE_MSI -BlobName $env:STABLE_MSI
|
||||
|
||||
# E2E: the Front Door route /agent/* must serve the fresh bytes on the public alias.
|
||||
- name: Verify download alias serves the MSI
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$localSize = (Get-Item $env:STABLE_MSI).Length
|
||||
$resp = Invoke-WebRequest -Uri "https://download.autopilotmonitor.com/agent/$env:STABLE_MSI" -Method Head -UseBasicParsing
|
||||
if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 }
|
||||
$remoteSize = [long]$resp.Headers['Content-Length'][0]
|
||||
if ($remoteSize -ne $localSize) {
|
||||
Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
|
||||
# Sigstore keyless provenance, same as the agent ZIP. Verify:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
@@ -77,12 +77,11 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright. Warnings are ratcheted: the cap is the current
|
||||
# count of accepted warnings (React-Compiler hooks-rule findings demoted
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
# raise the cap.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 99
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
@@ -107,8 +106,22 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
|
||||
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
|
||||
# node_modules by default, which npm ci recreates — so every CI run
|
||||
# re-downloaded from huggingface.co and rate-limits (429) failed the job
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
env:
|
||||
HF_CACHE_DIR: ${{ github.workspace }}/.hf-cache
|
||||
|
||||
guardrails-in-sync:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- 'rules/ime-log-patterns/**'
|
||||
- 'rules/schema/**'
|
||||
- 'rules/scripts/**'
|
||||
- 'rules/guardrails.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'rules/gather/**'
|
||||
@@ -15,6 +16,7 @@ on:
|
||||
- 'rules/ime-log-patterns/**'
|
||||
- 'rules/schema/**'
|
||||
- 'rules/scripts/**'
|
||||
- 'rules/guardrails.json'
|
||||
|
||||
jobs:
|
||||
validate-and-combine:
|
||||
@@ -55,9 +57,11 @@ jobs:
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
- name: Commit dist/ changes
|
||||
- name: Commit generated outputs
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: regenerate rules/dist from individual files"
|
||||
file_pattern: "rules/dist/*"
|
||||
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
|
||||
# combine.js writes THREE outputs; committing only dist/ would silently discard
|
||||
# the two generated TS modules and leave the guardrails-in-sync CI check red.
|
||||
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
|
||||
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
|
||||
env:
|
||||
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
@@ -48,6 +52,9 @@ jobs:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
|
||||
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
@@ -64,6 +71,55 @@ jobs:
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
# The SWA action reporting success proves the upload, not that the production host
|
||||
# serves the new build — verify against the live /version.json (Cache-Control:
|
||||
# no-store via staticwebapp.config.json) before publishing the version manifest.
|
||||
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
|
||||
- name: Verify deployed portal build is live
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
||||
$deadline = [DateTime]::UtcNow.AddMinutes(5)
|
||||
$attempt = 0
|
||||
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
|
||||
if ($v.commit -eq $expectedCommit) {
|
||||
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Attempt ${attempt}: serving $($v.commit) -- waiting for $expectedCommit"
|
||||
} catch {
|
||||
Write-Host "Attempt ${attempt}: /version.json not answering yet -- $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy —
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
- name: Publish version manifest
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
component = 'web'
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
Write-Host $manifest
|
||||
|
||||
close_pull_request_job:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
|
||||
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
|
||||
# verified without resetting a device. Like ci.yml it reports rather than blocks.
|
||||
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
|
||||
# blocks the merge button.
|
||||
#
|
||||
# There is deliberately no push trigger: publish-scripts.yml runs the very same
|
||||
# reusable gate workflow on every push to main and blocks the upload to the download
|
||||
# alias on it. A push trigger here would only duplicate that run, with no consequence.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -21,41 +25,5 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Publish customer scripts
|
||||
|
||||
# Keeps the scripts behind download.autopilotmonitor.com/agent/ current WITHOUT waiting
|
||||
# for an agent release. Before this workflow existed, the script blobs rotated only on a
|
||||
# publish_as_stable run of build-agent.yml, so a bootstrap fix sat unpublished until the
|
||||
# next agent cutover -- while the WDP bootstrap MSI downloads the published copy on every
|
||||
# enrolling device.
|
||||
#
|
||||
# Same publish code as the release path (.github/scripts/Publish-BootstrapScripts.ps1);
|
||||
# this workflow only supplies a second trigger for it.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run every check and report drift, upload nothing'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Shared with build-agent.yml: a script publish and an agent release both touch
|
||||
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
|
||||
# release writing its agent fields. Never cancel -- a half-published set is worse than a queue.
|
||||
concurrency:
|
||||
group: agent-blob-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
name: Publish to the download alias
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}' `
|
||||
-DryRun:$dryRun
|
||||
+10
@@ -154,3 +154,13 @@ docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
|
||||
# Local IME binaries scratch used by the /ime-decompile skill (MSI + msiexec-
|
||||
# extracted payload per version, for grepping in binaries). Decompilates live in
|
||||
# the private ime-decompiles repo, MSIs in the ime-archive blob - this stays local.
|
||||
ime-files/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
|
||||
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
|
||||
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
|
||||
against an already-referenced package must not block an unrelated hotfix deploy;
|
||||
advisories are handled by bumping the package, not by failing the build. -->
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into
|
||||
evidence-backed verdict, and the backend rule engine uses the error-code map for
|
||||
known-issue enrichment.
|
||||
|
||||
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
|
||||
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
|
||||
service, not by Autopilot. The event is still emitted with the full evidence payload plus
|
||||
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
|
||||
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
|
||||
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
|
||||
(`session.isCloudPc` or the event marker), the amber warning otherwise.
|
||||
|
||||
Consumers in this repo:
|
||||
|
||||
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
type: concept
|
||||
title: CMTrace Time Resolution — Per-Line Self-Anchoring
|
||||
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
|
||||
timestamp: 2026-08-20
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
A CMTrace line carries local time and no offset — IME 1.104 writes `DateTime.Now.TimeOfDay`
|
||||
in both of its trace listeners. Converting such a line with the reader's own
|
||||
`TimeZoneInfo.Local` silently assumes the writing and reading processes agree on the zone.
|
||||
They do not: each caches its zone at process start and follows neither a later `tzutil` nor
|
||||
a Windows auto-timezone change. The error is `Offset_writer − Offset_reader` — zero only
|
||||
when both believe the same thing, right or wrong. Field measurement over 11,068 sessions
|
||||
found 26 sessions with a real error, from +1 h to −17 h.
|
||||
|
||||
Two designs failed before this one:
|
||||
|
||||
* **Fix the reader's belief** (`TimeZoneInfo.ClearCachedData`, live OS offset): makes the
|
||||
agent isolated-correct and thereby breaks the majority of sessions, where two equally
|
||||
stale beliefs cancel.
|
||||
* **One measured offset per file** (agent 2.0.1410, reverted in `04b1a7c6`): a single log
|
||||
file holds lines from multiple writer *eras*. `AgentExecutor.log` is written by
|
||||
short-lived child processes whose zone belief flips per process — interleaved in one
|
||||
file, and flipping *back* (fixture:
|
||||
`tests/fixtures/cmtrace-logs/agentexecutor-two-writer-eras-v1.cmtrace`). Any cross-line
|
||||
anchor — per file, or nearest-in-write-order — applies one era's offset to the other
|
||||
era's lines. In the field this shifted `script_started` events by −9 h against their
|
||||
correct completions.
|
||||
|
||||
# Schema
|
||||
|
||||
The resolution rule, in order:
|
||||
|
||||
1. **Writer-declared bias** (`+480` suffix): authoritative, used as-is. Never a calibration
|
||||
anchor — it needs no measurement and must not overwrite one.
|
||||
2. **Per-line self-anchoring** — only for lines read in a *provably fresh* pass:
|
||||
|
||||
```
|
||||
offset_line = round((local − agentUtcNow) / 15 min) · 15 min
|
||||
lineUtc = local − offset_line
|
||||
```
|
||||
|
||||
Every real UTC offset is a whole multiple of 15 minutes, so the rounding absorbs poll
|
||||
and flush latency. No cross-line state exists, so interleaved eras resolve line by
|
||||
line — the property the fixture test pins. The line's own timestamp still contributes
|
||||
sub-poll precision and ordering (two lines 6 ms apart stay 6 ms apart; a plain
|
||||
`occurredAt = now` could not do that). Guards: grid residual ≤ 2 min, offset within
|
||||
UTC−12 … UTC+14.
|
||||
3. **Uniform reader-zone fallback** for everything else: wrong in absolute terms whenever
|
||||
the writer held a different belief, but *self-consistent* — both ends of a duration are
|
||||
wrong by the same amount, so derived durations stay right. The revert's core lesson:
|
||||
partially corrected is strictly worse than uniformly wrong.
|
||||
|
||||
**Freshness** is what makes rule 2 sound, and it is strict:
|
||||
|
||||
* Every poll look at a file stamps `LastCheckedUtc` (`LogFilePositionTracker.MarkChecked`),
|
||||
including "no new data" and an empty first sight — so a file first seen empty
|
||||
(`AgentExecutor.log` appearing mid-enrollment) anchors from its very first content.
|
||||
* A pass's lines are fresh only if the file was previously observed *in this process* and
|
||||
the gap since the last look is ≤ `FreshLineMaxAge` (30 s — far above the 100 ms poll,
|
||||
far below the 13-minute zone where a line's *age* could round onto the offset grid).
|
||||
* A restart-restored position bookmark deliberately carries **no** freshness: the first
|
||||
pass after a restart reads downtime backlog and must never anchor.
|
||||
|
||||
**Provenance** (`sourceOffsetOrigin` in event DataJson): `bias` | `line-anchored` |
|
||||
`reader-zone-fallback` (plus retired `calibrated` from 2.0.1410 events). The applied
|
||||
offset is always recorded (`sourceOffsetMinutes`), the raw local time too
|
||||
(`sourceLocalTs`), so any event can be re-derived later. The per-*file* measurement still
|
||||
runs, purely observationally (`measuredWriterOffsetMinutes`, Info log when it disagrees
|
||||
with the process zone).
|
||||
|
||||
# Constraints
|
||||
|
||||
* An anchored line resolves to `now ± 2 min` *by construction*. The accepted edge: a
|
||||
freshly written line carrying a **replayed** old timestamp whose age is exactly
|
||||
N×15 min ± 2 min anchors to ≈now instead of its replayed past — bounded error, and
|
||||
sub-24 h replays are treated as current by the historic-replay guard anyway.
|
||||
* Snapshot readers (`StallProbeCollector`, `LogParserCollector`) cannot tail and therefore
|
||||
cannot prove freshness — they stay on the marked fallback. This is a documented limit,
|
||||
not an omission; sharing the per-file measurement with them would re-apply exactly what
|
||||
the revert removed.
|
||||
* Mixed pairs remain possible across a restart (start resolved from backlog fallback,
|
||||
completion line-anchored) and are recognizable via provenance.
|
||||
|
||||
# Backend regression tripwire
|
||||
|
||||
The backend watches for the failure mode this design eliminates. On every terminal ingest
|
||||
batch, the counter reconcile's existing event-partition scan additionally collects
|
||||
`Δ = ReceivedAt − OccurredUtc` per event, split by `Source == "ImeLogTracker"` versus the
|
||||
rest (`CmTraceSkewTripwire`, hooked in `EventIngestProcessor`). If
|
||||
`median(Δ_IME) − median(Δ_other)` is a clean 15-minute multiple (residual < 2 min — the
|
||||
same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct
|
||||
upload batches per side, a `CmTraceTimeSkewRegression` ops event fires (category Agent,
|
||||
warning), carrying all numbers plus the session's `sourceOffsetOrigin` histogram.
|
||||
Bias-dominated sessions are suppressed (writer-declared offsets cannot be an anchoring
|
||||
regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
|
||||
flip-backs). Kill switch: app setting `CmTraceSkewTripwireDisabled`. Goal state: the event
|
||||
never fires — any hit is an anchoring case the per-line design misses, or a detector bug.
|
||||
|
||||
Device-clock problems (the customer-actionable cousin) are covered separately by the
|
||||
`clock_skew` analyze condition behind `ANALYZE-DEV-008`, which excludes IME-derived events
|
||||
precisely so an anchoring regression can never surface as a customer finding.
|
||||
|
||||
# Citations
|
||||
|
||||
* `CmTraceOffsetCalibrator.TryMeasureOffset` — the pure grid measurement, shared by
|
||||
anchoring and the observational per-file path.
|
||||
* `CmTraceSkewTripwire` + `CmTraceSkewTripwireTests` — the backend tripwire above.
|
||||
* `RuleEngine.ConditionEvaluators.EvaluateClockSkewCondition` + `RuleEngineClockSkewTests`
|
||||
— the customer-facing device-clock metrics (batch-median frames, spool-spread filter,
|
||||
plateau step detection, end-state persistence).
|
||||
* `ImeLogTracker.LogProcessing.cs` `ResolveEntryUtc` — the resolution order above.
|
||||
* `ImeLogTrackerLineAnchoringTests` — resolves-to-T across writer beliefs, interleaved
|
||||
eras in one chunk, freshness boundaries (first sight, empty first sight, poll gap,
|
||||
restart restore), the grid-replay edge, and the field fixture replayed at production
|
||||
poll cadence.
|
||||
* `CmTraceCalibratorFileIsolationTests` — pins per-file isolation of the observational
|
||||
measurement (session e9753578 logged a cross-file pairing the committed code could not
|
||||
be shown to produce; the mechanism remains open, the resolution path is immune by
|
||||
design, and `CalibrateFrom` logs the anchor timestamp as a tripwire).
|
||||
* `tasks/todo.md` (2026-08-20) — full evidence trail: field measurements, the revert,
|
||||
the two-writer-era fixture, and the disproven foreign-binary hypothesis (the SHA
|
||||
mismatch was the known release race against the 5-minute AdminConfiguration cache).
|
||||
@@ -143,11 +143,29 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
|
||||
first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted,
|
||||
typically by an interactive logon reclassifying the run as secondary-user; emits
|
||||
the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and
|
||||
`Timeout` (hard 60-min deadline). The abort rule reads the *persisted*
|
||||
`Timeout` (60-min deadline from detection). The abort rule reads the *persisted*
|
||||
`RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged`
|
||||
signal), so it survives agent restarts — the `RealmJoinDetected` replay path
|
||||
evaluates it too. A first observation of 210 does **not** release (RJ may have
|
||||
completed before agent boot and deploy afterwards).
|
||||
|
||||
**Activity-based timeout extension** (report 55e6afd61c9d — a large RJ package
|
||||
catalog outlived the 60-min window mid-install): when the timeout deadline fires
|
||||
while the first deployment is demonstrably active — `LastDeploymentPhase` 100/101
|
||||
*and* deployment activity (`RealmJoinPhaseChanged` or a package observation, tracked
|
||||
in `RealmJoinFacts.LastActivityUtc`; the phase captured at detection deliberately
|
||||
does not count) within the last 60 min — the handler re-arms the deadline to
|
||||
`lastActivity + 60 min` instead of timing out, emitting an Info
|
||||
`realmjoin_timeout_extended` event. Re-arms clamp to an absolute cap of
|
||||
**detection + 4 h**. Package activity is only observable at package *completion*
|
||||
(RJ writes the registry subkey when the install finishes), so a single install
|
||||
that is registry-silent for over 60 min still times out — the eventual
|
||||
`realmjoin_timeout` carries a `reason` payload (`hard_timeout` / `inactivity` /
|
||||
`absolute_cap`) telling the three shapes apart. A stale fire of an older deadline
|
||||
incarnation (armed due later than the fire time; `OccurredAtUtc = DueAtUtc` per
|
||||
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
|
||||
The idle case (detected, no deployment activity ever) times out at 60 min
|
||||
unchanged, and phase 200/210 activity never extends.
|
||||
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
|
||||
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
|
||||
timeline event with full audit payload.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
type: concept
|
||||
title: Diagnostics Package
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
|
||||
resource: agent
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
|
||||
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
|
||||
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
|
||||
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
|
||||
|
||||
## Archive layout
|
||||
|
||||
```
|
||||
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
|
||||
<built-in sections> catalog order, see below
|
||||
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
|
||||
package-manifest.txt every packaging decision (always written)
|
||||
_TRUNCATED.txt only when a cap skipped something
|
||||
```
|
||||
|
||||
## The built-in catalog is the single source of truth
|
||||
|
||||
Everything the package collects **before** any configured path is data, not code paths:
|
||||
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
|
||||
The agent iterates it in order; the backend serves the same list to the portal
|
||||
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
|
||||
agent does. There is no second copy to drift.
|
||||
|
||||
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
|
||||
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
|
||||
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
|
||||
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
|
||||
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
|
||||
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
|
||||
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
|
||||
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
|
||||
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
|
||||
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
|
||||
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
|
||||
|
||||
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
|
||||
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
|
||||
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
|
||||
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
|
||||
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
|
||||
name. Two sections may share a ZIP folder only when they read different source folders
|
||||
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
|
||||
|
||||
### Conditions
|
||||
|
||||
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
|
||||
effective configuration and the enrollment scenario; unknown values fail closed.
|
||||
|
||||
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
|
||||
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
|
||||
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
|
||||
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
|
||||
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
|
||||
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
|
||||
No ConfigVersion bump — no new wire field.
|
||||
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
|
||||
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
|
||||
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
|
||||
fallback rules count as Classic. The probe is a registry read, evaluated once per build
|
||||
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
|
||||
treated as Classic and logged — the evtx export is skipped, never the package. The channel
|
||||
is exclusively locked while active, so the existing `wevtutil epl` export path
|
||||
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
|
||||
|
||||
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
|
||||
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
|
||||
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
|
||||
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
|
||||
interactive user exists — identical to configured paths). Active ones log
|
||||
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
|
||||
`ADDED / NO MATCH / FOLDER MISSING` lines.
|
||||
|
||||
## Built-ins bypass the configured-path guards — on purpose
|
||||
|
||||
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
|
||||
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
|
||||
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
|
||||
exactly like the five sections that never went through the guard before — and
|
||||
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
|
||||
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
|
||||
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
|
||||
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
|
||||
|
||||
## Configured paths: global ∪ tenant
|
||||
|
||||
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
|
||||
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
|
||||
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
|
||||
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
|
||||
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
|
||||
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
|
||||
same-named folder share one ZIP folder.
|
||||
|
||||
## Portal surfaces
|
||||
|
||||
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
|
||||
neutral condition pills) above the editable global list; add-row first.
|
||||
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
|
||||
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
|
||||
read-only, then the tenant's own entries; every role with a tenant membership can read the
|
||||
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
|
||||
full values in tooltips.
|
||||
|
||||
## Test seams
|
||||
|
||||
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
|
||||
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
|
||||
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
|
||||
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
|
||||
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
|
||||
because Shared has no test project.
|
||||
|
||||
# Examples
|
||||
|
||||
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
|
||||
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
|
||||
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
|
||||
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
|
||||
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
|
||||
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
|
||||
ImeBootstrapperEventLog`. A tenant that had added
|
||||
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
|
||||
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
|
||||
entry is removed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
|
||||
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
type: Concept
|
||||
title: DO GroupId Auto-Set — Delivery Optimization peering group from a network fingerprint
|
||||
description: EnableDoGroupIdAutoSet (ConfigVersion 40) makes the agent set the DOGroupId policy value to a deterministic GUID derived from the default gateway's IPv4 address and MAC (RealmJoin-compatible byte layout), so devices on the same local network form one DO peering group during enrollment; foreign DOGroupId/DOGroupIdSource policies always win.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs
|
||||
tags:
|
||||
- agent
|
||||
- delivery-optimization
|
||||
- network
|
||||
- tenant-configuration
|
||||
timestamp: 2026-08-21T00:00:00+02:00
|
||||
---
|
||||
|
||||
# DO GroupId Auto-Set
|
||||
|
||||
## Schema
|
||||
|
||||
### The problem
|
||||
|
||||
With Delivery Optimization download mode Group (2), peering is scoped by GroupId. When no
|
||||
GroupId is configured, Windows derives it from AD site → authenticated domain SID → Entra
|
||||
tenant ID — for a cloud-native tenant that means **one tenant-wide group**, so "peers on
|
||||
the same network" degrades to "peers anywhere in the tenant" and DO's cloud service has to
|
||||
match them by public IP. A GroupId derived from the local network makes the group exactly
|
||||
"devices behind the same gateway" — including branch offices behind NAT. RealmJoin ships
|
||||
this as its network-fingerprint GroupId; our agent is on the device even earlier (during
|
||||
ESP, when the big app downloads actually happen), so it can seed the same value for
|
||||
Autopilot enrollments.
|
||||
|
||||
### The setting
|
||||
|
||||
`TenantConfiguration.EnableDoGroupIdAutoSet` (`bool?`, default null→false), served as
|
||||
`AgentConfigResponse.EnableDoGroupIdAutoSet` — **ConfigVersion 40**. Portal: Settings →
|
||||
Agent → Settings, toggle "Set Delivery Optimization Group ID", stored via the usual
|
||||
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
|
||||
tenant-config report. `RemoteConfigMerger` maps the bool 1:1; it survives the offline
|
||||
config cache like every plain feature flag.
|
||||
|
||||
The agent does NOT configure `DODownloadMode` — the fingerprint GroupId only has an
|
||||
effect when the tenant already deploys download mode Group (2) via Intune/GPO.
|
||||
|
||||
### The fingerprint
|
||||
|
||||
`DoGroupIdService.ComputeGroupId(ipv4Bytes, macBytes)` builds a 16-byte GUID:
|
||||
|
||||
```
|
||||
{101,48,6} + {0,0,0} + {ip[1], 0, ip[2], ip[3]} + mac[0..5] → new Guid(byte[16])
|
||||
```
|
||||
|
||||
Gateway discovery reuses the shared `NetworkInterfaceLocator` heuristic (Up, not
|
||||
Loopback/Tunnel, non-0.0.0.0 gateway; IPv4 gateway required), the gateway MAC comes from
|
||||
`ArpNativeMethods` (`iphlpapi!SendARP`). The byte layout — including the IP shuffle and
|
||||
the zero padding — is **RealmJoin-compatible and must never change**: devices managed by
|
||||
RealmJoin on the same network compute the identical GUID and peer with ours. The registry
|
||||
value is written as `Guid.ToString()` (braceless lowercase), identical to RealmJoin's
|
||||
`SetValue(name, Guid)` string form. Deliberate divergence from RealmJoin: **no
|
||||
random-GUID fallback** — a random per-device ID would isolate the device in a one-member
|
||||
group, which is worse than the OS default grouping, so no gateway/no MAC means skip.
|
||||
|
||||
### Ownership + conflict rules (never fight a foreign policy)
|
||||
|
||||
Target: `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization\DOGroupId`
|
||||
(64-bit registry view — the Policies store is WOW64-redirected; key paths reused from
|
||||
`DoThrottlePolicyReader`). Only this GPO-store value is written; the legacy pre-1607
|
||||
`GroupId` value name is intentionally not.
|
||||
|
||||
Skip conditions (Info event with `skipReason`, nothing written):
|
||||
|
||||
| skipReason | Meaning |
|
||||
|---|---|
|
||||
| `no_ipv4_gateway` | No active interface with an IPv4 default gateway |
|
||||
| `arp_failed` | Gateway MAC could not be resolved via SendARP |
|
||||
| `group_id_source_policy` | `DOGroupIdSource` configured in GPO or MDM store (dynamic group source wins) |
|
||||
| `mdm_policy_present` | `DOGroupId` managed via the Policy CSP store (Intune owns it) |
|
||||
| `foreign_value_present` | Existing GPO value neither matches the computed GUID nor the one we last wrote |
|
||||
|
||||
Ownership is tracked through the `StartupEventGate` fingerprint for the
|
||||
`do_group_id_auto_set` key: `written:<guid>` records the GUID we last wrote, so after a
|
||||
gateway change the agent recognizes its own stale value and updates it — while a value
|
||||
written by anyone else is never overwritten (`DecideAction`: empty→write, equal→no-op,
|
||||
ours→update, else→skip; unparseable existing values count as foreign). Every write is
|
||||
verified by readback.
|
||||
|
||||
### The event
|
||||
|
||||
One `do_group_id_auto_set` event per outcome change (Source `StartupEnvironmentProbes`,
|
||||
gated on the fingerprint — unchanged reruns across reboots stay silent; errors never
|
||||
latch, so each failing start keeps its retry trail). Severity: Info for
|
||||
success/skip, Warning for errors. Data: `groupId`, `previousValue`, `gatewayIp`,
|
||||
`gatewayMac`, `written`, `skipped`, `skipReason`, `success`, `error`.
|
||||
|
||||
## Examples
|
||||
|
||||
First boot on a network with gateway `192.168.178.1` / MAC `00-11-22-33-44-55`:
|
||||
|
||||
```
|
||||
do_group_id_auto_set (Info): Delivery Optimization GroupId set to
|
||||
00063065-0000-00a8-b201-001122334455 (gateway 192.168.178.1)
|
||||
```
|
||||
|
||||
Tenant already deploys a DO GroupId via Intune:
|
||||
|
||||
```
|
||||
do_group_id_auto_set (Info): Delivery Optimization GroupId not set: mdm_policy_present
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
* Service (fingerprint, ownership, write+readback): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs`
|
||||
* ARP interop: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ArpNativeMethods.cs`
|
||||
* Probe wiring + event + fingerprint dedup: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs`
|
||||
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
|
||||
* DO policy stores (key paths, Registry64 rationale): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs`
|
||||
@@ -44,7 +44,7 @@ tenant-config report.
|
||||
|
||||
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
|
||||
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log`; `false` → null, unless a
|
||||
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
|
||||
security-sensitive and deliberately **survives the offline config cache**.
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ How the on-device agent works:
|
||||
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
|
||||
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
|
||||
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
|
||||
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
|
||||
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
|
||||
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
|
||||
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
|
||||
* [DO GroupId Auto-Set](do-group-id-auto-set.md) - the optional Delivery Optimization peering-group seed: deterministic GUID from default gateway IPv4 + MAC (RealmJoin-compatible), written to the DOGroupId policy value with readback, ownership-tracked so foreign Intune/GPO values are never overwritten; skip reasons and the do_group_id_auto_set event.
|
||||
* [System Timeline Watcher](system-timeline-watcher.md) - clock steps (system_clock_changed: oldTime/newTime/timeDeltaMs/process, micro-slew floor) and completed sleep episodes (system_sleep_episode: sleep/hibernate/Modern Standby with authoritative enteredAt/exitedAt) from one System-channel EventLogWatcher with pre-agent backfill; feeds the web clock-set jump cause, the standby duration note, time-attribution SleepSpans and ANALYZE-DEV-012.
|
||||
|
||||
Reading order for newcomers: overview → decision engine → persistence.
|
||||
|
||||
@@ -53,7 +53,7 @@ every line; the spool fsyncs only items flagged `RequiresImmediateFlush`.
|
||||
| Event sequence | `State\event-sequence.json` | `{ LastAssignedSequence }` | atomic | none; survives WhiteGlove Part-2 resume |
|
||||
| Spool | `Spool\spool.jsonl` | JSONL (`TelemetryItem`/line) | only `RequiresImmediateFlush` items | none — never truncated, cursor skips uploaded items |
|
||||
| Upload cursor | `Spool\upload-cursor.json` | `{ LastUploadedItemId }` | atomic | idempotent, never regresses |
|
||||
| Agent trace log | `Logs\agent_YYYYMMDD[_NNN].log` | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
|
||||
| Agent trace log | `Logs\agent.log` (segments `agent-NNN.log`) | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
|
||||
| Crash dump + record | `Crashes\<stem>.dmp` + `.json` | MiniDump + JSON | n/a (process dying) | 5 files / 7 days |
|
||||
| Startup-event gate | `State\startup-event-state.json` | JSON dict | no (atomic) | cross-reboot dedup of one-shot probes |
|
||||
| Bandwidth state | `State\bandwidth-state.json` | JSON samples/counters | no (atomic) | reboot-survivor for the DO bandwidth estimate |
|
||||
|
||||
@@ -146,7 +146,7 @@ snapshots) are executed by the `EffectRunner` — the reducer itself is pure. De
|
||||
| `\Agent\` | deployed binaries |
|
||||
| `\State\` | decision persistence (SignalLog, Journal, Snapshot), `enrollment-complete.marker`, `final-status.json`, self-update state, `.quarantine/`, `.part1-<ts>/` |
|
||||
| `\Spool\` | telemetry spool (offline queue) |
|
||||
| `\Logs\` | agent trace log, `crash_*.log`, `ime_pattern_matches.log` |
|
||||
| `\Logs\` | agent trace log (`agent.log`), `crash-*.log`, `ime-pattern-matches.log` |
|
||||
| `\Config\` | `remote-config.json` cache |
|
||||
| `\Agent-Update\`, `\Updates\` | self-update staging/download |
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
type: Concept
|
||||
title: ESP Completion Starvation — the sits-d Cloud-PC complex
|
||||
description: Why five Windows 365 Cloud PCs finished enrolling but never completed in the product — a SkipUser=true flow produces none of the signals every completion-gate evaluation site hangs off — plus the two adjacent defects fixed alongside (edge-only user-apps-settled synthesis, orphaned Hello-wizard restart replay) and what a Shell-Core replay deliberately does not do.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
|
||||
tags:
|
||||
- agent
|
||||
- esp
|
||||
- completion
|
||||
- skip-user
|
||||
- reboot
|
||||
- recovery
|
||||
timestamp: 2026-08-20T00:00:00+02:00
|
||||
---
|
||||
|
||||
# ESP Completion Starvation — the sits-d Cloud-PC complex
|
||||
|
||||
Seven Windows 365 Cloud PCs, same tenant, first sign-in the same morning. Two sessions
|
||||
succeeded within minutes; five sat at `Completion is waiting on: hello_resolution` from
|
||||
second five onward and ran into the six-hour max-lifetime watchdog — verdict `Incomplete`
|
||||
on devices whose users were working normally the whole time.
|
||||
|
||||
The first diagnosis blamed the mid-ESP reboot (DeviceGuard/DmaGuard, see
|
||||
[MDM Reboot Coalescing](mdm-reboot-coalescing.md)): the agent is killed in
|
||||
`Stage=EspAccountSetup` and, so the theory went, loses the Shell-Core ESP exit to the
|
||||
downtime. The evidence disproved it. `8110e262` ran **six hours uninterrupted** after its
|
||||
last reboot with the Shell-Core watcher armed — and Windows never wrote a 62407 in all
|
||||
that time, because on these machines **the user ESP page never existed**.
|
||||
|
||||
# Schema
|
||||
|
||||
## The actual defect — a satisfied gate nobody evaluates
|
||||
|
||||
The five stuck machines carried `SkipUserStatusPage=True` in the enrollment `FirstSync`
|
||||
key (read 10+ times across three agent processes, `True` on every read — not a stale
|
||||
single read). Windows honours it: no user ESP page, hence
|
||||
|
||||
* no Shell-Core 62407 (`esp_exiting`) — the page that would exit does not exist,
|
||||
* no `AccountSetupCategory` provisioning registry — nothing writes categories for a page
|
||||
that never renders,
|
||||
* desktop within seconds of sign-in.
|
||||
|
||||
The reducer is *built* for this flow: **arm B** of `ShouldTransitionToAwaitingHello`
|
||||
(`SkipUserEsp == true`) declares the gate open, and the missing-prerequisites bookkeeping
|
||||
already exempts the AccountSetup gate — which is why the sessions reported only
|
||||
`hello_resolution` as missing. But the gate is a predicate, not a process. Something has
|
||||
to evaluate it, and every evaluation site hangs off a signal a skip-user flow structurally
|
||||
cannot produce:
|
||||
|
||||
| Evaluation site | Carrier signal | On SkipUser=true |
|
||||
|---|---|---|
|
||||
| `HandleEspExitingV1` | Shell-Core 62407 | never fires |
|
||||
| `HandleEspPhaseChangedV1(FinalizingSetup)` | Shell-Core 62407 via coordinator | never fires |
|
||||
| `HandleDesktopArrivedV1` fast-path | requires Hello policy **disabled** | Hello was enabled |
|
||||
| `HandleImeUserSessionCompletedV1` arm-C attempt | requires the recorded final exit | unsatisfiable |
|
||||
|
||||
No knock, no promotion, no `HelloSafety` — and the `AdvisoryCompletion` backstop is armed
|
||||
exclusively from an `EspExiting` signal, so it never armed either. The session idles to
|
||||
the watchdog with an open gate.
|
||||
|
||||
## The fix — the observed skip is the exit evidence
|
||||
|
||||
`HandleImeUserSessionCompletedV1`'s completion attempt now accepts the observed skip in
|
||||
place of the final exit (`skipUserExitEquivalent`): on a flow where no page exists, no
|
||||
exit can ever say more than the `FirstSync` read already did. Everything else stays
|
||||
mandatory — AccountSetup anchor, genuine (at-or-after-anchor) IME user-session completion,
|
||||
DAD-validated real-user desktop — which makes this knock **stricter** than the existing
|
||||
Shell-Core-carried arm-B promotion (pinned in `ClassicAwaitingHelloGuardTests`, which
|
||||
requires no IME or desktop evidence at all). Hello semantics ride the existing rails:
|
||||
enabled-or-unknown promotes to `AwaitingHello` with `HelloSafety` armed (on Cloud PCs
|
||||
reached via RDP the Hello wizard never appears, so the synthetic timeout resolves the
|
||||
wait), disabled completes directly with the synthetic `Skipped` outcome.
|
||||
|
||||
The knock deliberately lives on the IME user-session edge, not on desktop arrival:
|
||||
|
||||
* **Ordering-robust against Fix 10.** An `EspPhaseChanged(AccountSetup)` arriving on
|
||||
`AwaitingHello` bounces the stage back and cancels `HelloSafety` (the premature-promotion
|
||||
guard). The IME AccountSetup phase line lands *after* the desktop on this flow, so a
|
||||
desktop-side knock would be undone two signals later. The IME phase line always precedes
|
||||
the user-session-complete line in log order, so a bounced promotion is re-knocked by the
|
||||
very next re-emission — including the restart re-parse.
|
||||
* **Right semantics.** The IME user session completes when the required-app processing is
|
||||
done; a desktop-side knock would mark the session `Succeeded` mid-installation.
|
||||
* Device Preparation is excluded — WDP keeps its Hello+Desktop conjunction and the
|
||||
`DevicePrepCompletion` backstop.
|
||||
|
||||
`ClassicSkipUserEspUserSessionCompletionTests` replays the real `8110e262` signal ordering
|
||||
end to end (bootstrap `EspConfigDetected` → Hello enabled → desktop → AccountSetup anchor →
|
||||
IME completion → HelloSafety → `enrollment_complete`), including the Fix-10
|
||||
bounce-then-re-knock cycle, and pins the negative space (no skip observed / no desktop /
|
||||
ghost pre-anchor IME completion / WDP).
|
||||
|
||||
Why five of seven identically administered Cloud PCs carried `SkipUserStatusPage=True` is
|
||||
a tenant-side question (ESP profile assignment state at each machine's provisioning
|
||||
moment); the value did not change during any observed session, and machine age does not
|
||||
cleanly explain the split. The agent's job is to complete the flow Windows actually ran —
|
||||
which it now does.
|
||||
|
||||
## Adjacent defect — the synthesis was edge-triggered
|
||||
|
||||
This one is real but belongs to the `SkipUser=false` flow (the two sessions that
|
||||
succeeded). The user-apps-settled synthesis
|
||||
(`MaybeSynthesizeAccountSetupCompleteFromSettledUserApps`, session `caa6cf50`) ran **only**
|
||||
from `OnEspExited`. On a tenant with ~138 required apps the ESP page exits while apps are
|
||||
still in flight: the single attempt misses and nothing ever re-checks, even when every
|
||||
tracked app reaches a terminal state minutes later.
|
||||
|
||||
The ESP exit is an **edge** (happens once); settled user-ESP apps are a **level** (reached
|
||||
later). `EspAndHelloTracker` records the edge in `_espExitObserved` and exposes
|
||||
`ReevaluateUserAppsSettledSynthesis()`, chained into `ImeLogTracker.OnAppStateChanged` and
|
||||
into the post-restore `onStateRestored` callback (a live exit observed before the IME
|
||||
state restore completes would otherwise never get a second look). Gate conditions are
|
||||
unchanged — no new completion path, only a second opportunity for the existing one. Only a
|
||||
**live, confirmed post-AccountSetup** exit may be remembered
|
||||
(`IsConfirmedPostAccountSetupExit`, stricter than the `IsIntermediateDeviceEspExit`
|
||||
forward guard: unknown counts as not confirmed).
|
||||
|
||||
## Adjacent defect — the restart replay was never wired up
|
||||
|
||||
`ShellCoreTracker`'s backfill had existed since session `772fe502` with **no caller**; the
|
||||
Shell-Core `EventLogWatcher` only delivers records written after `Start()`.
|
||||
`EspAndHelloHost.Start()` now calls it as `BackfillRecentHelloWizardStart` — which is
|
||||
exactly what it does and all it does:
|
||||
|
||||
| Record | Replayed | Why |
|
||||
|---|---|---|
|
||||
| 62404 Hello-wizard start | **yes** | Conservative: vetoes a premature "Hello is disabled" skip, can never complete a session by itself. The observation `772fe502` was about. |
|
||||
| 62407 ESP exit | **no** | Cannot be placed in time — see below. |
|
||||
| 62407 ESP failure | **no** | Re-injecting a historic failure as fresh can fail a session that recovered on retry (`ANALYZE-ESP-006`). |
|
||||
|
||||
A replayed exit is unusable, and every candidate ordering mechanism fails independently:
|
||||
|
||||
1. Windows writes the **identical** description `CommercialOOBE_ESPProgress_Page_Exiting`
|
||||
for the intermediate DeviceSetup→AccountSetup transition and the final exit.
|
||||
2. Every post-hoc ordering probe (AccountSetup registry, settled-apps) reads state as it
|
||||
is **now**, not as it was at the event's time.
|
||||
3. The reducer orders exits by **ingest ordinal**, not timestamp
|
||||
(`IsPostAccountSetupFinalExit`) — a historic exit replayed today gets a fresher ordinal
|
||||
than reality and reads as post-AccountSetup by construction.
|
||||
4. `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit; with
|
||||
restored state, arm C would open on a historic intermediate exit.
|
||||
|
||||
`ClassicEspExitingOnRestoredStateTests` pins that the reducer *does* open on any arriving
|
||||
exit; `ShellCoreTrackerReplayScopeTests` pins that the replay never produces one. Skipped
|
||||
62407 records are counted and reported once as an `agent_trace`
|
||||
(`reason: replayed_62407_not_orderable`) — silently dropping evidence is how this class of
|
||||
bug survives.
|
||||
|
||||
**Residual gap, named honestly:** a `SkipUser=false` session whose final exit falls
|
||||
exactly into an agent downtime *and* whose AccountSetup category never resolves
|
||||
(`1ec8f4c6` shape) still has no completion carrier after the restart. The normal case is
|
||||
covered — `ProvisioningStatusTracker` reads the current registry at startup, so a
|
||||
category that resolved during the downtime fires arm A. The residual shape is rare,
|
||||
surfaced by `ANALYZE-ESP-005` via the `session_stalled` interim trigger, and deliberately
|
||||
not patched with a replay that cannot be ordered.
|
||||
|
||||
### Sizing the replay window
|
||||
|
||||
A constant is provably wrong: the scheduled task has a `BootTrigger` only
|
||||
(`Program.InstallMode.BuildScheduledTaskXml`) — after an `exception_crash` the agent does
|
||||
not return until the next boot. `ResolveEspExitBackfillLookbackMinutes` reaches back to
|
||||
the last moment the previous run is known to have been alive (max of `snapshot.json`
|
||||
mtime and `LastBootUtc` for kill-type exits), floored at 5 minutes, capped at 360, off on
|
||||
`first_run`, and immune to cross-reboot clock skew (a future timestamp is ignored).
|
||||
|
||||
# Examples
|
||||
|
||||
Tenant `sits-d.cloud`, 2026-08-19, seven Windows 365 Cloud PCs, first sign-ins the same
|
||||
morning. The discriminator is a single registry value:
|
||||
|
||||
| Session | SkipUser | `esp_exiting` | AccountSetup registry | Reboots | Outcome |
|
||||
|---|---|---|---|---|---|
|
||||
| `08fc6bda` | False | yes | yes | 0 | Succeeded (10 min) |
|
||||
| `3b9291aa` | False | yes | yes | 0 | Succeeded (2.1 h) |
|
||||
| `8110e262` | True | — | — | 2 | Incomplete (6 h watchdog) |
|
||||
| `a89aac2d` | True | — | — | 1 | Incomplete |
|
||||
| `e7ba63c9` | True | — | — | 1 | Incomplete |
|
||||
| `cb4a485a` | True | — | — | 2 | Incomplete |
|
||||
| `3d6278fb` | True | — | — | 1 | Incomplete |
|
||||
|
||||
The reboot correlation was a red herring: `8110e262` ran six hours live after its final
|
||||
reboot and would have seen every signal. `8110e262` and `a89aac2d` both reached
|
||||
`138/138 completed, 0 failed` and a genuine IME user-session completion — with the fix,
|
||||
all five complete a few minutes after their IME user session settles.
|
||||
|
||||
`08fc6bda` exercises the edge-level fix on the same day: ESP page exited normally while
|
||||
`categorySucceeded` never confirmed — completed via the user-apps-settled synthesis.
|
||||
`3b9291aa` shows the independent starvation visibility gap: 99 `app_install_starved`
|
||||
events on a session recorded as `Succeeded` — surfaced from now on by `ANALYZE-APP-016`.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` — `HandleImeUserSessionCompletedV1`, `skipUserExitEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs` — `ShouldTransitionToAwaitingHello` (arm B)
|
||||
* `src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicSkipUserEspUserSessionCompletionTests.cs` — end-to-end replay of the 8110e262 ordering
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs` — `BackfillRecentHelloWizardStart`, `ReplayBackfillRecords`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs` — `_espExitObserved`, `ReevaluateUserAppsSettledSynthesis`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs` — lookback sizing, `OnAppStateChanged` chaining
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) — the policy attribution for the reboots themselves
|
||||
* [Decision Engine](decision-engine.md) — the AccountSetup gate and completion arms
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Power-State Watcher — live AC/battery transitions and threshold events during enrollment
|
||||
description: How the agent observes AC↔battery switches and downward battery-threshold crossings mid-enrollment via a WMI push subscription (no polling), the power_state_change event semantics (transition field, 50/30/15 ladder, latch/debounce/cap), and the two analyze rules on top (ANALYZE-DEV-009/-010).
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs
|
||||
tags:
|
||||
- agent
|
||||
- power
|
||||
- battery
|
||||
- collector
|
||||
- wmi
|
||||
- analyze-rules
|
||||
timestamp: 2026-08-21T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Power-State Watcher
|
||||
|
||||
The startup probe (`power_state_check`, one-shot from `StartupEnvironmentProbes` via
|
||||
`PowerStateProbe`/`GetSystemPowerStatus`) only captures the power state at agent start. A
|
||||
device unplugged mid-enrollment — or draining its battery across a multi-hour ESP — was
|
||||
invisible. The watcher closes that gap **event-driven, with zero polling**.
|
||||
|
||||
# Mechanism
|
||||
|
||||
- `PowerStateWatcherHost` (`Orchestration/Hosts`) arms a WMI event subscription with a
|
||||
two-step chain:
|
||||
1. `SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE:
|
||||
true kernel push, fires on AC↔DC switches and battery-percentage steps).
|
||||
2. **Fallback** `SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance
|
||||
ISA 'Win32_Battery'` — required because on Windows 11 24H2/25H2 (observed on builds
|
||||
26200 and 26220, session 161b838c) the `MS_Power_Management_Event_Provider` is still
|
||||
registered in the repository but its activation fails with `WBEM_E_NOT_FOUND`: the
|
||||
legacy push provider no longer loads on exactly the OS builds new devices enroll with.
|
||||
The WITHIN sampling runs inside WinMgmt (one Win32_Battery snapshot every 30 s), not in
|
||||
the agent; worst-case detection latency ≈ 35 s incl. debounce. `collector_degraded`
|
||||
(`watcher_arm_failed`) is emitted only when BOTH attempts fail; the agent log's arm
|
||||
line names the active mode.
|
||||
|
||||
Either way the arriving event's payload is ignored — each arrival re-probes via the
|
||||
existing `PowerStateProbe.Probe()` (cheap P/Invoke).
|
||||
- Snapshots are diffed by the pure `PowerStateTransitionTracker`
|
||||
(`Monitoring/Enrollment/SystemSignals`), which decides what to emit. The host only owns
|
||||
WMI, a 5 s trailing-edge debounce timer (dock-flap AC→battery→AC inside the window
|
||||
collapses to a no-diff) and the emit path (`InformationalEventPost`, single rail).
|
||||
- **No battery / probe error ⇒ WMI is never armed** — desktops and VMs pay nothing. WMI
|
||||
arming runs on a background thread (WinMgmt can hang in OOBE; `ConsoleBypassWatcher`
|
||||
precedent); an arm failure emits one `collector_degraded` (`watcher_arm_failed`).
|
||||
- Registered as a plain always-on peripheral host after `NetworkChangeHost`, deliberately
|
||||
NOT under `PeriodicCollectorLifecycleHost`: it is push-based (zero idle cost) and must be
|
||||
alive precisely when the device idles on battery. Kill-switch:
|
||||
`Collectors.EnablePowerStateWatcher` (default true, compile-time default like the other
|
||||
collector toggles; ConfigVersion 38).
|
||||
|
||||
# Event: power_state_change
|
||||
|
||||
`data.transition` discriminates three shapes (payload always carries `onAcPower`,
|
||||
`batteryPercent`, `isCharging`, `batteryLifeMinutes`; unknowns as `"unknown"`):
|
||||
|
||||
| transition | Severity | Immediate | Notes |
|
||||
|---|---|---|---|
|
||||
| `ac_to_battery` | Warning | yes | one per accepted edge |
|
||||
| `battery_to_ac` | Info | no | recovery |
|
||||
| `threshold_crossed` | Info (50) / Warning (30) / Error (15) | 30+15 only | extra field `thresholdPercent` |
|
||||
|
||||
Threshold semantics (tracker):
|
||||
|
||||
- Ladder 50/30/15, evaluated only while ON battery; **each level latches once per agent
|
||||
lifetime** (charge-up + re-drain does not re-emit — no plug-cycle alarm spam).
|
||||
- A multi-level jump emits only the LOWEST newly-crossed level (60%→10% ⇒ one `15` event).
|
||||
- Entering battery power already below a level counts as crossing it; the arm-time baseline
|
||||
does too — an enrollment STARTING at 12% battery emits its `thresholdPercent: 15` event
|
||||
immediately (the analyze rule would otherwise never see sessions that began low).
|
||||
- Lifetime emission cap 20 (storm backstop against a flapping dock / dying battery
|
||||
controller); the host logs one warning when it engages.
|
||||
|
||||
Backend/agent hygiene: `power_state_change` is classified as non-activity on both sides —
|
||||
`SignalActivityClassifier.NonActivityEventTypes` (agent: must not reset the
|
||||
PeriodicCollectorLifecycleHost/StallProbeHost idle clocks) and
|
||||
`EventIngestProcessor.IsPeriodicOrStallEvent` (backend: must not heal a Stalled session —
|
||||
battery drain is not enrollment progress).
|
||||
|
||||
# Analyze Rules
|
||||
|
||||
- **ANALYZE-DEV-009** (high): battery crossed 15% while on battery — matches the
|
||||
agent-stamped `thresholdPercent equals 15`.
|
||||
- **ANALYZE-DEV-010** (warning): AC→battery switch — matches `transition equals
|
||||
ac_to_battery`.
|
||||
- Both: `evaluateOn: ["enrollment_end", "whiteglove_sealed", "on_event:power_state_change"]`
|
||||
(live interim finding + terminal finalization), `markSessionAsFailedDefault: false`.
|
||||
- Both are deliberately **single-required-condition**: `event_data` conditions scan events
|
||||
independently (no same-instance join), so a two-condition rule (e.g. `transition ==
|
||||
threshold_crossed AND batteryPercent < 15`) could false-positive across two different
|
||||
power events. `thresholdPercent` exists only on threshold events and its value pins the
|
||||
ladder level, so one condition fully identifies the instance.
|
||||
- `{{token}}` caveat: `AddDataFieldsToEvidence` whitelists only generic identifiers
|
||||
(appId/errorCode/…), so `{{batteryPercent}}` would render literally. DEV-009 interpolates
|
||||
`{{thresholdPercent}}` (the matched condition's own dataField); the exact percentage lives
|
||||
on the linked event in the timeline.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/PowerStateProbe.cs` (probe, unchanged)
|
||||
- `rules/analyze/ANALYZE-DEV-009.json`, `rules/analyze/ANALYZE-DEV-010.json`
|
||||
- [analyze-rule-triggers](../rules/analyze-rule-triggers.md) (interim-trigger design; the WG battery rule was its motivating case)
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
type: concept
|
||||
title: Registry App-State Second Pillar
|
||||
description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
resource: agent
|
||||
tags: [ime, registry, win32apps, observability, reconciliation, pattern-drift, wdp]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Why a second pillar
|
||||
|
||||
The 2026-08-17 audit of the decompiled IME (1.97.107 / 1.104.102) proved that IME log
|
||||
wording is an unstable contract: Microsoft removed the old V1/V2 processing pipeline
|
||||
(killing 9 patterns silently), reworded the post-remediation line, changed the
|
||||
report-delta quoting between 1.97 and 1.104, and deleted `[DO TEL]` entirely. The
|
||||
registry, by contrast, is IME's *internal product contract* — the ESP page and Intune
|
||||
reporting consume it — and cannot drift casually.
|
||||
|
||||
Architecture decision (Oliver, 2026-08-17): **hybrid**. Logs stay the narrative
|
||||
(downloads, retries, exit codes, scripts — the timeline). The registry is the stable
|
||||
truth for terminal state. Divergence between the two IS the alarm that log patterns
|
||||
drifted — turning silent data loss into a Warning event.
|
||||
|
||||
## Observed surfaces
|
||||
|
||||
All under `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension` (64-bit view), verified
|
||||
against decompiled IME source:
|
||||
|
||||
| Surface | Content |
|
||||
|---|---|
|
||||
| `Win32Apps\<userGuid>\<appGuid>_<rev>` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. |
|
||||
| `EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev>` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. |
|
||||
| `SideCarPolicies\StatusServiceReports\<userGuid>\<appId>` | The `AppInstallStatusReport` the ESP page renders (`Status`: 1000=Installed, 1001=Installing, 3000=Failed, 2000er=NotApplicable). |
|
||||
|
||||
## Mechanism
|
||||
|
||||
`ImeRegistryAppStateObserver` + `ImeRegistryAppStateHost` (always-on, no config gate —
|
||||
precedent `EspPolicyProviderStallHost`):
|
||||
|
||||
- **One recursive `RegistryWatcher`** on the IME root covers all three surfaces;
|
||||
changes are debounced (2 s trailing edge) into a tick. A 60-s periodic tick is both
|
||||
the reconciliation-settle evaluator and the poll-only fallback when the watcher
|
||||
fails to arm (`collector_degraded`, once).
|
||||
- **Snapshot-and-diff**: `RegNotifyChangeKeyValue` gives coalesced key-scope edges
|
||||
only, so every tick re-reads the surfaces and diffs against the previous snapshot.
|
||||
- **Silent baseline**: the state present at agent start is captured without events.
|
||||
Win32Apps keys survive re-enrollments — replaying them would be the registry twin
|
||||
of the historic-IME-replay bug. Only apps that *change during the session* are
|
||||
reported or judged.
|
||||
- **`registry_app_state`** (Info): emitted per real field change
|
||||
(enforcementState/errorCode/exitCode/statusServiceStatus/espTracked/espPhase),
|
||||
capped at 200/session (cap announced once as Warning).
|
||||
- **`app_state_reconciliation`** (Warning, once per app, immediate upload): after a
|
||||
changed entry has been terminal (EnforcementState 1000er=success / 5000er=error,
|
||||
or StatusService 1000er/3000) for ≥ 90 s, its outcome is compared against the
|
||||
log-derived `AppPackageState` from `ImeLogHost.AllKnownPackageStates`. Divergence
|
||||
reasons: `registry_error_log_installed`, `registry_success_log_error`,
|
||||
`app_unknown_to_log_tracking` (only when the tracker is actively tracking apps —
|
||||
an idle tracker is not judgeable).
|
||||
- **Pure observability**: nothing feeds the DecisionEngine (same rule as
|
||||
oobe-state observability).
|
||||
|
||||
## WDP reuse (Paket 3)
|
||||
|
||||
The same host archetype (RegistryWatcher subtree + debounce + snapshot-diff) is the
|
||||
intended vehicle for WDP live progress: watch
|
||||
`HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DevicePreparation\BootstrapperAgent`
|
||||
(`ProgressUpdates` rows, `ResumeContext.ProvisioningResult` terminal marker) with the
|
||||
RealmJoinWatcher appearance-watcher pattern for not-yet-existing keys, plus an
|
||||
Event-142 channel watcher (`Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider`,
|
||||
channel handling precedent in `DiagnosticsPackageService`). Win32Apps keys are written
|
||||
on WDP devices too — the pillar works on both rails unchanged.
|
||||
|
||||
# Examples
|
||||
|
||||
Timeline evidence of a drifted pattern set: apps install fine (registry says
|
||||
EnforcementState 1000) but the timeline shows no `app_install_completed` — each app
|
||||
raises one `app_state_reconciliation` with `reason=app_unknown_to_log_tracking`.
|
||||
Operator response: run `/ime-pattern-validate` against the session, fix patterns,
|
||||
backend-deploy (no agent release needed for pattern fixes).
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs`
|
||||
- `tasks/ime-v1-code-audit-2026-08-17.md` (decompiled-source audit; registry surface catalog)
|
||||
- Decompile archive: `okieselbach/ime-decompiles` (private), `C:\Code\GitHubRepos\ime-decompiles\<version>\decompiled`
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
type: Concept
|
||||
title: System Timeline Watcher — clock steps and sleep episodes from the System event log
|
||||
description: How the agent turns two enrollment blind spots into timeline ground truth via one EventLogWatcher on the System channel — system_clock_changed (Kernel-General 1, oldTime/newTime/timeDeltaMs/process) and system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate, Kernel-Power 507 Modern Standby) — including backfill of pre-agent records, the payload-times-are-authoritative rule, SleepSpans in time attribution, the web clock-set jump cause, and ANALYZE-DEV-012.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs
|
||||
tags:
|
||||
- agent
|
||||
- eventlog
|
||||
- time
|
||||
- clock-skew
|
||||
- standby
|
||||
- collector
|
||||
- time-attribution
|
||||
- analyze-rules
|
||||
timestamp: 2026-08-21T00:00:00+02:00
|
||||
---
|
||||
|
||||
# System Timeline Watcher
|
||||
|
||||
Two things silently distort every session timeline and its wall-clock duration:
|
||||
|
||||
1. **The OS clock gets stepped** — classically w32time correcting a wrong BIOS clock early
|
||||
in OOBE, but also tzutil/MDM actions. Before this watcher the jump was only *inferred*
|
||||
(TimeJumpBadge, `clock_skew`/DEV-008); Windows records the ground truth.
|
||||
2. **The device sleeps mid-enrollment** — the timeline shows an unexplained gap, the
|
||||
duration looks inflated, and nothing says "the device was simply asleep for 56 minutes".
|
||||
|
||||
`SystemTimelineTracker` (hosted by `SystemTimelineWatcherHost`, config flag
|
||||
`Collectors.EnableSystemTimelineWatcher`, ConfigVersion 39) subscribes to the **System**
|
||||
channel with one provider-qualified XPath and forwards both facts as events.
|
||||
|
||||
# Sources and events
|
||||
|
||||
| Source (System channel) | Emitted event | Key payload |
|
||||
|---|---|---|
|
||||
| `Microsoft-Windows-Kernel-General` EventID 1 | `system_clock_changed` | `oldTime`, `newTime` (ISO-8601), signed `timeDeltaMs`, `reason`/`reasonText` (1=`application_set`, 2=`hardware_clock_sync`), `processName`/`processId` |
|
||||
| `Microsoft-Windows-Power-Troubleshooter` EventID 1 | `system_sleep_episode` | `kind`=`sleep`/`hibernate` (EffectiveState 5 ⇒ hibernate), `enteredAt`=SleepTime, `exitedAt`=WakeTime, `durationSeconds`, `wakeSourceType`/`wakeSourceText` |
|
||||
| `Microsoft-Windows-Kernel-Power` EventID 507 | `system_sleep_episode` | `kind`=`modern_standby`, `exitedAt`=TimeCreated, `enteredAt`=exit − DurationInUs, `durationSeconds` (scenario), `sleepDurationSeconds` (real sleep), `reason`, `onAcPower`, `batteryRemainingCapacityOnExit` |
|
||||
|
||||
**506 is deliberately not consumed.** 507 carries the full episode; pairing 506/507 would
|
||||
need an orphan-tolerant state machine across agent restarts for zero information gain — an
|
||||
episode without a 507 never completed and would not be emitted under any design.
|
||||
|
||||
# Thresholds (constants, not knobs)
|
||||
|
||||
- `MinClockDeltaMs = 2000` — w32time logs Kernel-General 1 for routine **1 ms micro-slews**
|
||||
(verified live, several per hour); only genuine steps are emitted. Suppressed records are
|
||||
still claimed by the dedup watermark.
|
||||
- `ClockDeltaWarningMs = 5 min` — larger steps are Warning + immediate upload (can break
|
||||
token/cert validation windows; matches the web TimeJumpBadge threshold).
|
||||
- `MinSleepDurationSeconds = 60` — **Modern Standby only**: every screen-off scenario logs a
|
||||
506/507 pair, including seconds-long ones with `SleepDurationInUs 0` (`SleepEntered=false`);
|
||||
the single duration gate suppresses both. Classic Power-Troubleshooter episodes have no
|
||||
floor — they only exist for genuinely completed S3/S4 transitions.
|
||||
|
||||
# Payload times are authoritative
|
||||
|
||||
The decisive clock correction happens exactly when the device clock is untrustworthy, and
|
||||
the backend clamps implausible event timestamps (±168 h/24 h) to server time. Every semantic
|
||||
instant therefore travels as an explicit ISO-8601 payload field (`oldTime`/`newTime`,
|
||||
`enteredAt`/`exitedAt`); consumers must never read the event's own `Timestamp` for these.
|
||||
The event `Timestamp` is set to the record's TimeCreated (WindowsUpdateTracker backfill
|
||||
convention) so rows land where the wake/step happened.
|
||||
|
||||
# Backfill and dedup
|
||||
|
||||
Template: `WindowsUpdateTracker`. `LoadWatermark → arm watcher → backfill` with lookback
|
||||
`SystemEventBackfillLookbackMinutes` (default 1440 = 24 h; 0 disables). The System .evtx
|
||||
persists across OOBE reboots, so the pre-agent w32time correction and pre-agent standby
|
||||
episodes are recovered with `backfilled=true`. Cross-restart dedup via RecordId watermark
|
||||
(`system-timeline-watermark.json` in the state directory) plus an intra-run `HashSet` —
|
||||
deliberately **not** a high-water mark, which would drop exactly the older, never-emitted
|
||||
pre-agent records (the live watcher arms before the backfill scan).
|
||||
|
||||
# Non-activity classification (both sides)
|
||||
|
||||
Both event types are environment observation, not enrollment progress:
|
||||
`SignalActivityClassifier.NonActivityEventTypes` (agent — must not reset idle/stall clocks)
|
||||
and `EventIngestProcessor.IsPeriodicOrStallEvent` (backend — a device that slept through its
|
||||
stall must stay Stalled).
|
||||
|
||||
# Consumers
|
||||
|
||||
- **Web timeline** (`EventTimeline.tsx`): badge blocks for both events; the TimeJumpBadge
|
||||
gains the ground-truth cause `clock-set` — `classifyTimeJump` receives the session's
|
||||
`system_clock_changed` deltas and matches a backward display step against a backward clock
|
||||
set within max(60 s, 20 %) (`lib/timeProvenance.ts`).
|
||||
- **Duration story** (`SessionInfoCard` via `useSessionDerivedData.standbySeconds`): the
|
||||
wall-clock duration deliberately keeps the pause (the enrollment really took that long);
|
||||
a `· 🌙 56m standby` note explains it. Episodes are dedup'd on `enteredAt`.
|
||||
- **Time attribution** (`TimeAttributionCalculator.BuildSleepSpans`, AttributionVersion 3):
|
||||
`SleepSpan`s mirror `RebootSpan`s — cross-cutting annotations clipped to the observation
|
||||
windows (a WhiteGlove-pause episode contributes only its in-window flanks), never a slice
|
||||
of the wall-clock partition. Persisted as `SleepSeconds`/`SleepSpansJson`; rendered as the
|
||||
standby chip in `TimeAttributionLane`.
|
||||
- **ANALYZE-DEV-012** (info, `trigger: single`, `on_event:system_sleep_episode`): fires once
|
||||
per session for episodes ≥ 5 min. Deliberately the ONLY new rule: clock issues already
|
||||
have DEV-008 (`clock_skew`) — a second clock rule would double-report the same action item
|
||||
(user decision 2026-08-21); clock steps stay timeline ground truth with badges instead.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/SystemTimelineWatcherHost.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` (`BuildSleepSpans`)
|
||||
- `src/Web/autopilot-monitor-web/lib/timeProvenance.ts` (`clock-set`, `findExplainingClockChange`)
|
||||
- `rules/analyze/ANALYZE-DEV-012.json`
|
||||
- Live payload evidence (2026-08-21, Win11 26220): Kernel-General 1 with `TimeDeltaInMs=1`
|
||||
micro-slews; Kernel-Power 507 with `SleepDurationInUs=2324095807`; Power-Troubleshooter 1
|
||||
with `SleepTime`/`WakeTime` across a hibernate.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: concept
|
||||
title: WDP Scenario Gates & Completion Backstop
|
||||
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
|
||||
resource: agent
|
||||
tags: [wdp, device-preparation, decision-engine, collectors, completion]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Windows Device Preparation (Autopilot v2, "WDP") has **no deployment profile and no ESP**.
|
||||
Before the afee7ae0 audit (2026-08-18) the agent ran the full Classic apparatus anyway:
|
||||
misleading `autopilot_profile_missing` warnings, a ZTD event-log query + HTTP probe,
|
||||
`esp_config_detected` "unknown/unknown" noise, an Autopilot-channel noise flood
|
||||
(EventIDs 100/417/418/1005, 900+ occurrences per session), a subtree registry watcher
|
||||
woken by every WDP progress write, and — worst — no completion path when Hello never
|
||||
resolved (park until the 360-min watchdog).
|
||||
|
||||
## Detection: deterministic vs. fallback
|
||||
|
||||
`EnrollmentRegistryDetector` classifies v2 through three rules. Only the first is
|
||||
**deterministic WDP**:
|
||||
|
||||
1. `AutopilotSettings\DevicePreparation\BootstrapperAgent` subkey with non-empty
|
||||
`ExecutionContext` → the WDP policy orchestration tree, present before the agent
|
||||
starts. Exposed as `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`
|
||||
and the `DevicePreparationExecutionContextRule` constant.
|
||||
2./3. `CloudAssignedDeviceRegistration == 2` / `CloudAssignedEspEnabled == 0` —
|
||||
profile-derived legacy signals. They imply a REAL Autopilot profile (just ESP-less),
|
||||
so every gate below deliberately ignores them: **gates require the deterministic
|
||||
marker and fail toward Classic behavior.**
|
||||
|
||||
## Agent-side gates (all keyed to the deterministic marker)
|
||||
|
||||
* `DeviceInfoCollector.CollectAutopilotProfile`: no `autopilot_profile` event, no
|
||||
`autopilot_profile_missing` warning, no ZTD event-log query, no ZTD HTTP probe.
|
||||
`enrollment_type_detected` still fires. (WDP devices DO carry an
|
||||
`AutopilotPolicyCache` with `ProfileAvailable=0` / error 807 `ZtdDeviceIsNotRegistered`
|
||||
— Windows runs the ZTD download regardless — so the probe fired for real before.)
|
||||
* `CollectEspConfigurationLocked`: `esp_config_detected` is emitted only when at least
|
||||
one FirstSync value or ESP tracking list exists (generic null-gate, mirrors the
|
||||
orchestrator bootstrap's signal gate) — fixes "unknown/unknown" for WDP AND plain
|
||||
Entra joins.
|
||||
* `DefaultComponentFactory`: `EspPolicyProviderStallHost` (60-s poll of the ESP-only
|
||||
`EnrollmentStatusTracking` key) is not created on WDP.
|
||||
* `EspAndHelloTracker.Start`: `ProvisioningStatusTracker` is not started on WDP — its
|
||||
subtree watcher sits on `AutopilotSettings`, exactly where WDP's BootstrapperAgent
|
||||
writes progress, so it woke on every write only to read three ESP category values
|
||||
that never exist there.
|
||||
* `ModernDeploymentTracker`: `DevicePreparationNoiseEventIds` {100, 417, 418, 1005}
|
||||
are suppressed entirely on WDP (before `FormatDescription()`); Critical (level 1)
|
||||
always passes; genuine WDP diagnostics (e.g. 408) keep flowing. The Autopilot error
|
||||
backfill (807/809/815/908 replay) is skipped — 807 is the EXPECTED state on WDP.
|
||||
The watcher itself stays on: WDP's provisioning stack logs into the same channels.
|
||||
* `StallProbeCollector`: skips the ESP category registry scan on WDP and unions the
|
||||
noise IDs into its harmless set; the channel scans remain (they carry genuine WDP
|
||||
diagnostics during a stall).
|
||||
* `EnrollmentTerminationHandler`: the `app_install_starved` terminal sweep is skipped
|
||||
on WDP — post-desktop installs are the DESIGNED order there (only DPP-policy apps
|
||||
run during provisioning); per-app data still travels via `app_tracking_summary` and
|
||||
final-status.json.
|
||||
|
||||
## DecisionCore: WDP completion without ESP facts
|
||||
|
||||
* **Seeding**: `EnrollmentFactsObserved` carries `enrollmentTypeDeterministic`;
|
||||
the deterministic marker seeds `DevicePreparation` at **High** confidence (same
|
||||
registry-determinism standard as the self-deploying OobeConfig bits), the fallback
|
||||
rules stay Medium. The three explicit downgrade guards from d0ab3eee remain as
|
||||
defence in depth.
|
||||
* **Arm D** (`ShouldTransitionToAwaitingHello`): on WDP the DAD-validated real-user
|
||||
desktop is a legitimate promotion basis — arms A–C are unsatisfiable by construction
|
||||
(no provisioning categories, no Shell-Core 62407 final exit). With a disabled Hello
|
||||
policy the fast-path now completes at desktop arrival.
|
||||
* **Prerequisites**: `completion_waiting` never lists
|
||||
`account_setup_provisioning_complete` on WDP (unsatisfiable; mirrors the
|
||||
`skipUserEsp` exemption).
|
||||
* **Backstop**: `DeadlineNames.DevicePrepCompletion` (30 min, replay-safe base) is
|
||||
armed by `HandleDesktopArrivedV1` when a WDP session parks desktop-first with Hello
|
||||
pending. On fire it mirrors the HelloSafety timeout: synthetic
|
||||
`HelloOutcome=Timeout` + completion through Finalizing. This closes the
|
||||
"no Hello → park until watchdog" hole; a timer surviving into a terminal stage is
|
||||
swallowed by the post-terminal dispatch guard.
|
||||
* **Tripwire**: `DecisionStepProcessor.IsParkedWithoutDeadline` has a WDP branch
|
||||
(dead-end zone = desktop arrived) so a regression in the backstop becomes visible
|
||||
as `session_parked_without_deadline`.
|
||||
|
||||
# Examples
|
||||
|
||||
Session afee7ae0 (the audit trigger): Succeeded in 9:32 min, but its timeline carried
|
||||
`autopilot_profile` (807), an `autopilot_profile_missing` WARN, `esp_config_detected`
|
||||
unknown/unknown, 30 `modern_deployment_log` rollup events and 10 `app_install_starved`
|
||||
entries — all structurally meaningless on WDP. After the gates, the same session shape
|
||||
emits none of these; completion rode Hello-skipped + desktop as before.
|
||||
|
||||
Deliberately NOT changed (open follow-ups, see the WDP audit topic memory): the ESP
|
||||
phase vocabulary (`esp_phase_changed`/stage names ride the Classic rails on WDP),
|
||||
subscription to the `BootstrapperAgentServiceLogProvider` event channel, and
|
||||
`registry_app_state` as the primary WDP app source.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs` — deterministic rule + helper
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — profile/ESP emission gates
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs` — `DevicePreparationNoiseEventIds`
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs` — backstop deadline
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs` — High/Medium seeding
|
||||
* Tests: `DevicePreparationEngineTests`, `ModernDeploymentTrackerDevicePrepTests`, `EnrollmentTerminationHandlerTests.StarvedApps_skipped_on_device_preparation`, `DeviceInfoCollectorEspRefreshTests.NoEspEvidenceAtAll_SuppressesTheEvent`
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
type: concept
|
||||
title: Attempt-Scoped App Install Durations
|
||||
description: AppInstallSummary.DurationSeconds measures the status-defining (last) install attempt via a LastAttemptStartedAt max-fold anchor — replacing the first-observation-to-last-terminal span that billed multi-pass IME processing gaps to trivial apps; time attribution counts the union of active attempt segments.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs
|
||||
tags: [backend, agent, ingest, app-installs, metrics, time-attribution, durations]
|
||||
timestamp: 2026-08-21
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
The IME processes the ESP app list in multiple passes: a device-ESP evaluation pass can
|
||||
touch an app (often ending `Skipped`) long before the real install runs in a later pass.
|
||||
`AppInstallSummary` is keyed by `{SessionId}_{AppName}` and folded from ALL of the app's
|
||||
events, so `DurationSeconds = last terminal − first observation` spanned those passes.
|
||||
Field case (session `44862cd3`, "Set TimeZone"): evaluation pass 09:16, real install
|
||||
10:22:58 → 10:23:21 — reported 4002 s for a 23-second install. Fleet averages, the
|
||||
Slowest-Apps ranking, p95s, and the version-regression radar all inherited the inflation;
|
||||
the rule engine's `app_install_duration` (already attempt-scoped) contradicted the
|
||||
dashboard for the same install.
|
||||
|
||||
# Schema
|
||||
|
||||
Three durable anchors on the row — each with a distinct, non-interchangeable job:
|
||||
|
||||
| Column | Fold | Role |
|
||||
|---|---|---|
|
||||
| `StartedAt` | min (earliest wins) | First observation. Server-side window/bucket filter of every AppInstallSummaries scan and the radar's version-ordering key. Never re-anchored. |
|
||||
| `LastAttemptStartedAt` | **max** (in-batch and in `ReconcileAppInstallSummaryWithExisting`) | Anchor of `DurationSeconds`. Null = sentinel (row predates 2026-08, or no start observed) → duration falls back to the historical `StartedAt` span. Replay of an older batch can never regress it. |
|
||||
| `InstallPassCount` | additive, guarded by `LastAttemptStartedAt` monotonicity (a batch whose newest start is not newer than the stored anchor adds 0) | Explains "finished long after first seen" (web ×N chip); 2+ is normal. |
|
||||
|
||||
`DurationSeconds = CompletedAt − LastAttemptStartedAt`, computed at the terminal event and
|
||||
re-derived in one central reconcile site covering in-order and Q4 out-of-order batches.
|
||||
A `LastAttemptStartedAt` newer than `CompletedAt` means a fresh pass is in flight — the
|
||||
completed attempt's stored duration stands (the 0 value omits the column; Merge preserves).
|
||||
`DownloadDurationSeconds` pairs the latest `app_download_started` with the NEXT
|
||||
`app_install_started` (attempt-scoped, in-batch). The full span stays derivable as
|
||||
`CompletedAt − StartedAt` — deliberately not a column.
|
||||
|
||||
Weaker-terminal guard (both in-batch and cross-batch): `app_install_completed` with
|
||||
state `Skipped`/`Postponed` never overrides an `Installed`/`Error` terminal, nor its
|
||||
`CompletedAt`/`DurationSeconds` — mirroring the long-pinned `app_install_skipped` rule.
|
||||
|
||||
# Consumers
|
||||
|
||||
* **Fleet metrics / apps dashboard / PDF** (`MetricsMath`, `AppsAnalyticsHelper`): values
|
||||
drop to attempt scale; labels say "install time (final attempt)". The 6 h
|
||||
`MaxPlausibleInstallDurationSeconds` gate remains (an attempt longer than the agent's
|
||||
lifetime is still unobservable).
|
||||
* **AppVersionRegressionRadar**: cutover gate in `MeasuredAppGroups` — only rows with
|
||||
`LastAttemptStartedAt` enter the duration population, on BOTH comparison sides. A mixed
|
||||
population would mask real regressions and falsely re-arm active episodes for up to the
|
||||
35-day horizon. `MinMeasuredInstalls` keeps it silent until enough post-cutover rows
|
||||
exist. Thresholds (2.0× / 300 s) were tuned on span data — re-evaluate ~4 weeks after
|
||||
rollout.
|
||||
* **Time attribution** (`TimeAttributionCalculator.BuildBlockingAppIntervals`):
|
||||
sequence-ordered start→terminal pairing yields one segment per pass/attempt. Per-app
|
||||
seconds = SUM of clamped segments (failed attempts and evaluation passes count — they
|
||||
occupied the path); occupancy merges the segments, never the per-app hulls; hull
|
||||
endpoints remain first-start→last-terminal for display and what-if. Deliberately
|
||||
BROADER than the metrics duration: active time across all passes vs. final attempt.
|
||||
* **Agent payloads** (`AppInstallTiming` / `ImeLogTrackerAdapter.UpdateAppTiming`): a
|
||||
terminal→active transition re-arms BOTH endpoints, so event `durationSeconds` also
|
||||
describes the attempt (needs the agent release carrying this change).
|
||||
* **Rule engine** `app_install_duration`: was already attempt-scoped — now consistent.
|
||||
|
||||
# Rollout
|
||||
|
||||
No backfill (user decision, analog to the CMTrace legacy): pre-cutover rows keep span
|
||||
values, so time-series charts step down visibly at the changeover (documented in the
|
||||
public platform changelog). Sentinel-gated columns follow the standard chain:
|
||||
`BuildAppInstallSummaryEntity` + `MapToAppInstallSummary` + `AppsDashboardProjection`
|
||||
(the only projection whose consumers read the new columns).
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs` — fold + weaker-terminal guard + attempt pairing
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs` — reconcile max-fold, pass-count guard, central duration recompute
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs` — cutover gate
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — per-pass segments + occupancy
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs` — payload timing re-arm
|
||||
* Tests: `AppInstallTerminalStateAndReconcileTests`, `EventTimestampValidationTests`, `AppVersionRegressionRadarTests`, `TimeAttributionCalculatorTests`
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
type: Concept
|
||||
title: App Version Duration Regression Radar
|
||||
description: How the radar detects app versions whose median install duration regressed vs. the previous version (median lift ≥2x plus an absolute floor over first-seen-ordered versions), how alert episodes live in the appversionregression tracker keyspace, and which surfaces show them.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs
|
||||
tags:
|
||||
- backend
|
||||
- apps
|
||||
- regression-radar
|
||||
- insights
|
||||
timestamp: 2026-08-13T12:00:00+02:00
|
||||
---
|
||||
|
||||
# App Version Duration Regression Radar
|
||||
|
||||
Detects "version X made this app's installs much slower" — the concrete, quotable insight
|
||||
("median install duration rose from 11 to 29 min after version X") — from the per-(session,
|
||||
app) `AppInstallSummaries` rows. Duration only: failure-rate per version is already visible
|
||||
in the apps dashboard's `versionBreakdown`, and rate regressions belong to Wilson-interval
|
||||
statistics, not medians.
|
||||
|
||||
# Schema
|
||||
|
||||
## Signal & detection
|
||||
|
||||
Per (tenant, app), the MEASURED install population is: `Status == "Succeeded"`, terminal
|
||||
state not Skipped/Postponed, `MetricsMath.HasMeasuredDuration` (1s..6h — 0s means the start
|
||||
was unobserved, above 6h means back-stamped batches), no `AppIdCollision`, non-empty
|
||||
`AppVersion`. Versions are ordered by FIRST-SEEN install time (min `StartedAt`), never by
|
||||
string comparison ("9.1" vs "2024.10" would lie). The comparison pair is always the newest
|
||||
version (the one with the most recent measured install) vs. the version with the latest
|
||||
first-seen strictly before it.
|
||||
|
||||
MEDIANS, not means (nearest-rank, shared with the apps dashboard via
|
||||
`AppsAnalyticsHelper.Percentile`) — one back-stamped straggler must not fire a fleet alert.
|
||||
A regression fires only when ALL hold over the trailing 35-day horizon:
|
||||
|
||||
1. both versions have ≥ 10 measured installs;
|
||||
2. current median ≥ 2× the previous version's median;
|
||||
3. absolute increase ≥ 300 s (a 20s→45s app is noise, not an incident).
|
||||
|
||||
## Suppression & limits
|
||||
|
||||
* Parallel ring rollouts can make "previous" a concurrently-deployed version — accepted in
|
||||
v1; the episode key (app, current version) still caps it at one bell per version.
|
||||
* Platform kill switch: app setting `AppVersionRegressionRadarDisabled=true` skips the
|
||||
radar (fail-open — it only notifies, never mutates data).
|
||||
* One cross-tenant install-summary read per pass (the same query the global apps dashboard
|
||||
runs), grouped by tenant; per-tenant failures are non-fatal.
|
||||
|
||||
## Alert episodes (tracker keyspace)
|
||||
|
||||
An episode lives as `appversionregression|{app-lower}|{version-lower}` (table-key-sanitized;
|
||||
raw AppName/CurrentVersion live in columns, the mapper never parses the RowKey) in the
|
||||
notification-tracker table, next to the `ruleregression|` keyspace: the row IS the dedup
|
||||
(bell + ops event fire exactly once per episode) and the `versionRegressions[]` payload —
|
||||
both medians, both sample counts, lift. Numbers refresh on every radar pass while the
|
||||
episode stays active; `FirstNotifiedAt` never moves. The episode closes (dedup re-arms) when
|
||||
the alerted version drains under 10 measured installs in the horizon or its recomputed
|
||||
median falls back under 1.5× the previous version's median; the tracker's 30-day retention
|
||||
sweep also re-arms, so a month-old still-burning regression rings once more by design.
|
||||
|
||||
## Notification chain (v1: bell + ops event)
|
||||
|
||||
* Tenant bell `app_version_duration_regression` (audience: Admin), href
|
||||
`/apps/detail?name={app}&days=35` (canonical `appDetailUrl` shape). Message carries the
|
||||
full numbers so the admin can verify without a portal round-trip.
|
||||
* Ops event `AppVersionDurationRegression` (Tenant category, Warning) — dual-registered in
|
||||
the web `OPS_EVENT_TYPES` catalog so operators can wire Telegram/Teams/Slack alert rules.
|
||||
|
||||
## Surfaces
|
||||
|
||||
* `apps/{appName}/analytics` (+ global twin with `?tenantId=`) responds with
|
||||
`versionRegressions[]` (empty for the cross-tenant aggregate — episodes are per-tenant
|
||||
rows), and `versionBreakdown[]` rows now carry `measuredInstalls` /
|
||||
`medianDurationSeconds` / `p95DurationSeconds`.
|
||||
* Apps detail page: amber "↑ Duration regression" banner above the version cards and a
|
||||
"Median Install Duration by Version" bar chart.
|
||||
|
||||
# Examples
|
||||
|
||||
A tenant ships Contoso VPN 2.4.0 after 2.3.9. 2.3.9 has 40 measured installs, median 660s;
|
||||
2.4.0 accumulates 12 measured installs with median 1740s. Lift 2.6×, absolute +1080s →
|
||||
episode `appversionregression|contoso vpn|2.4.0` opens, one bell + one ops event fire.
|
||||
Subsequent passes refresh the numbers only. When 2.4.1 replaces 2.4.0 and 2.4.0 drains
|
||||
below 10 measured installs in the horizon, the episode deletes and the dedup re-arms.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs` — pure core
|
||||
(gates, first-seen ordering, re-arm).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs`
|
||||
— orchestration, kill switch, bell/ops-event emission, wording contract.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs`
|
||||
— episode keyspace, entity round-trip.
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs` — persisted
|
||||
alert payload.
|
||||
* [Rule Regression Radar](rule-regression-radar.md) — the sibling pattern this radar mirrors.
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Client-Certificate Tenant Binding — Closing the Cross-Tenant Gap
|
||||
description: Why a chain-valid Intune MDM certificate does not prove the caller belongs to the tenant it claims, which certificate extension carries the Entra tenant id (and which two look like it but do not), and the shadow-then-enforce rollout that measures field coverage before it can lock devices out.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- authentication
|
||||
- certificates
|
||||
- intune
|
||||
- multi-tenant
|
||||
timestamp: 2026-08-24T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Agent requests authenticate with mTLS. `CertificateValidator` pins the chain to the embedded
|
||||
Intune roots (`X509ChainTrustMode.CustomRootTrust`), checks the validity window and requires the
|
||||
Client-Authentication EKU. All of that together proves exactly one thing: **the certificate was
|
||||
issued by the Microsoft Intune MDM Device CA to some tenant**.
|
||||
|
||||
Those roots are shared by every Intune tenant on the planet. Nothing in the certificate stage ties
|
||||
the caller to the tenant in the request. The tenant scoping came entirely from the downstream
|
||||
device validators — the serial number must appear in that tenant's Autopilot / imported-device /
|
||||
Cloud PC inventory. Device serial numbers are not secrets; they are printed on the chassis.
|
||||
|
||||
So the pre-existing attack was: enroll a device in your own Intune tenant, obtain a legitimate
|
||||
device certificate, learn one serial number of the victim tenant, and submit telemetry as that
|
||||
tenant. Certificate validation passes, device validation passes.
|
||||
|
||||
# Mechanism
|
||||
|
||||
The Intune MDM Device CA stamps the customer's identifiers into the leaf certificate. Since the
|
||||
agent presents this certificate in the TLS handshake, its contents are proof of possession and the
|
||||
backend can compare them against the requested tenant — no Graph call, no additional consent, and
|
||||
nothing the agent has to send.
|
||||
|
||||
## Which extension carries what
|
||||
|
||||
Verified by decoding a real field certificate (issued 2026-05-08, `Functions.Tests/device-cert-sample.pem`):
|
||||
|
||||
| OID | Encoding | Content |
|
||||
| --- | --- | --- |
|
||||
| `1.2.840.113556.5.14` | nested OCTET STRING (`04 10` + 16 bytes) | **Entra tenant id** — the one to compare |
|
||||
| `1.2.840.113556.5.6` | nested OCTET STRING (`04 10` + 16 bytes) | Intune **Account** id — a *different* GUID, never equal to the tenant id |
|
||||
| `1.2.840.113556.5.4` | **16 raw bytes, no wrapper** | Intune device id, identical to the certificate's Subject CN |
|
||||
|
||||
Two traps are worth stating explicitly, because both cost real debugging time:
|
||||
|
||||
1. `…5.6` is widely described as "the tenant id (AccountID)". It is not the Entra tenant id.
|
||||
Comparing it against the requested tenant would reject every request in production.
|
||||
2. The encodings are not uniform. `…5.4` holds the GUID bare while the other two nest it in a
|
||||
second OCTET STRING, so a decoder written against one shape silently fails on the other.
|
||||
`MsDeviceCertificateOids.TryParseGuid` accepts both, and all GUIDs are Microsoft
|
||||
little-endian (what `new Guid(byte[])` expects).
|
||||
|
||||
A 16-byte input is inherently ambiguous — it is indistinguishable from a truncated wrapper and is
|
||||
always read as a bare GUID. That is harmless here: the value is only ever compared for equality
|
||||
against a known tenant id, never trusted as a standalone claim.
|
||||
|
||||
## Where it is NOT
|
||||
|
||||
`EntraDeviceCertHelper` reads tenant and device ids from the **MS-Organization-Access** certificate
|
||||
(`1.2.840.113556.1.5.284.*`). That certificate is not part of the authentication path at all — the
|
||||
agent selects the Intune MDM certificate by exact issuer match (`CN=Microsoft Intune MDM Device CA`,
|
||||
see `CertificateHelper.SelectMdmCertificate`) and the backend pins only the MDM chain. The Entra
|
||||
certificate is used purely as a local fallback for `TenantIdResolver`; it never leaves the device.
|
||||
|
||||
Sending it in a header instead would prove nothing: only the certificate used in the TLS handshake
|
||||
demonstrates possession of a private key. A certificate copied into a custom header can be replayed
|
||||
by anyone who has seen it once.
|
||||
|
||||
# Rollout
|
||||
|
||||
Enforcing immediately would be reckless: any device population whose certificates predate the
|
||||
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
|
||||
rollout therefore has staged consequences.
|
||||
|
||||
**Stage 1 — shadow (current).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
|
||||
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
|
||||
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
|
||||
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
|
||||
`Unparseable`, `RequestTenantNotAGuid`) are stable and queried by exact match in KQL.
|
||||
`WouldRejectUnderEnforcement` records what stage 2 *would* have done, so the telemetry can be read
|
||||
without re-deriving the rule at query time.
|
||||
|
||||
The telemetry uses two carriers, because the numerator and the denominator have very different
|
||||
costs and answer different questions:
|
||||
|
||||
* **Every** outcome, `Match` included, is stamped onto the request row as the `CertTenantBinding`
|
||||
dimension — `SecurityValidator` writes it into `FunctionContext.Items`
|
||||
(`CertTenantBinding.RequestItemKey`) and `RequestTelemetryMiddleware` copies it onto the
|
||||
`RequestTelemetry` it already emits. That row exists per request and is unsampled, so the
|
||||
denominator costs no additional telemetry at all.
|
||||
* Outcomes that need an enforcement decision (mismatch, missing, undecodable) are **additionally**
|
||||
logged at Warning with the full context — thumbprint, certificate tenant, session.
|
||||
|
||||
`Match` is deliberately not a trace line. Worker-side `LogInformation` never reaches App Insights
|
||||
(the ApplicationInsightsLoggerProvider default rule is Warning+ and host-forwarding of worker logs
|
||||
is disabled), so a `Match` trace is dead code that silently costs the denominator. The first
|
||||
deployment of this feature made exactly that mistake: it emitted zero rows across 32k requests,
|
||||
which is indistinguishable from the code never running.
|
||||
|
||||
**Stage 2 — enforce.** Once telemetry shows the `ExtensionMissing` rate is negligible, `Mismatch`
|
||||
becomes a rejection. The decision that still has to be made is the policy for certificates without
|
||||
the extension: skip with a warning, or reject. Grep marker for every stage-2 site:
|
||||
`CERT-TENANT-BINDING-SHADOW`.
|
||||
|
||||
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
|
||||
unaffected by either stage.
|
||||
|
||||
# Examples
|
||||
|
||||
Coverage — the question stage 2 depends on ("would enforcing lock anyone out?"):
|
||||
|
||||
```kusto
|
||||
requests
|
||||
| where isnotempty(customDimensions.CertTenantBinding)
|
||||
| summarize Requests = sum(itemCount)
|
||||
by Outcome = tostring(customDimensions.CertTenantBinding),
|
||||
Tenant = tostring(customDimensions.TenantId)
|
||||
| order by Requests desc
|
||||
```
|
||||
|
||||
Detail on the individually actionable ones:
|
||||
|
||||
```kusto
|
||||
traces
|
||||
| where message has "AgentCertTenantBinding"
|
||||
| extend Outcome = tostring(customDimensions.Outcome),
|
||||
Tenant = tostring(customDimensions.TenantId)
|
||||
| summarize count() by Outcome, Tenant
|
||||
```
|
||||
|
||||
Anything in `Mismatch` is a cross-tenant certificate; anything in `ExtensionMissing` is a device
|
||||
that stage-2 enforcement would have to make a policy decision about. Note that the trace query
|
||||
alone can never show `Match`, so it cannot tell "everything matched" apart from "nothing ran" —
|
||||
use the request-row query for that.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
|
||||
shared ASN.1 GUID decoder (one implementation for agent and backend).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs` — extension extraction
|
||||
alongside chain validation, cached per thumbprint.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs` — the pure comparison.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — shadow observation.
|
||||
* [W365 Cloud PC Device Validation](cloudpc-device-validation.md) — the other consumer of this
|
||||
certificate's identity fields (Subject CN = Intune device id).
|
||||
@@ -78,7 +78,8 @@ Durations shown for attempts are the sessions' authoritative `DurationSeconds` v
|
||||
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
|
||||
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
|
||||
success session (the platform's StartedAt-date convention; late terminals converge via
|
||||
the sweep). All counts are **additive**, so a window rate is the ratio of summed daily
|
||||
the sweep, which slices the tick's shared projected window scan — see
|
||||
`time-attribution.md`). All counts are **additive**, so a window rate is the ratio of summed daily
|
||||
rows — there is no rolling-window row (unlike the median-based time-attribution
|
||||
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
|
||||
on purpose (the UI needs the n for "insufficient data (n=…)").
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: concept
|
||||
title: Lifecycle Manifests & Session Scope Resolution
|
||||
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
|
||||
resource: backend
|
||||
tags: [lifecycle, offboarding, sessionsindex, manifest, scope-resolution, search, performance]
|
||||
timestamp: 2026-08-13
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
|
||||
|
||||
## 1. Table lifecycle buckets
|
||||
|
||||
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
|
||||
|
||||
| Bucket | Mechanism | Source of truth |
|
||||
|---|---|---|
|
||||
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
|
||||
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
|
||||
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
|
||||
|
||||
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
|
||||
|
||||
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
|
||||
|
||||
## 2. SessionsIndex field manifest
|
||||
|
||||
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
|
||||
|
||||
## 3. Session scope resolution (one implementation)
|
||||
|
||||
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
|
||||
|
||||
- Non-global callers always keep the middleware-validated `TargetTenantId`.
|
||||
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
|
||||
- `requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
|
||||
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
|
||||
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
|
||||
|
||||
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
|
||||
|
||||
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
|
||||
|
||||
## 4. Server-side dashboard search
|
||||
|
||||
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
|
||||
|
||||
# Examples
|
||||
|
||||
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
|
||||
|
||||
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs` (`ResolveSessionScopeAsync`)
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs` (Variant D)
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` (`MatchesFreeText`, backfill loop)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
type: concept
|
||||
title: Plan Downgrade Policy & the Retention Grace Period
|
||||
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
|
||||
resource: backend
|
||||
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
|
||||
timestamp: 2026-08-22
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## The downgrade model: gate, don't destroy
|
||||
|
||||
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
|
||||
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
|
||||
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
|
||||
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
|
||||
(≤ ~5 min config cache):
|
||||
|
||||
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
|
||||
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
|
||||
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
|
||||
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
|
||||
assignments are never touched. Resurrects on re-upgrade.
|
||||
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
|
||||
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
|
||||
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
|
||||
|
||||
**Retention is the one exception**: the retention sweep (12-hourly
|
||||
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
|
||||
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
|
||||
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
|
||||
irreversibly cascade-deleting the 90–365-day band on the next tick.
|
||||
|
||||
## The retention grace period (30 days)
|
||||
|
||||
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
|
||||
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
|
||||
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
|
||||
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
|
||||
swept, grace or not.
|
||||
|
||||
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
|
||||
returns null while effectively Pro; grace end = latest anchor + 30d):
|
||||
|
||||
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
|
||||
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
|
||||
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
|
||||
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
|
||||
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
|
||||
the trial timestamp disappears and could not anchor). Cleared on any transition back to
|
||||
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
|
||||
resets the grace clock.
|
||||
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
|
||||
(read-time, like edition resolution). A planTier downgrade under a still-active trial
|
||||
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
|
||||
anchors the grace then.
|
||||
|
||||
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
|
||||
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
|
||||
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
|
||||
which is correct: no tenant had been downgraded before the feature existed.
|
||||
|
||||
## Ops visibility (dual-registered event types)
|
||||
|
||||
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
|
||||
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
|
||||
response body also returns `retentionGraceEndsUtc`.
|
||||
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
|
||||
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
|
||||
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
|
||||
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
|
||||
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
|
||||
|
||||
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
|
||||
so Telegram alert rules can route them.
|
||||
|
||||
## Deliberate non-goals
|
||||
|
||||
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
|
||||
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
|
||||
grace a tenant admin cannot SET a value above the Community cap. The grace protects
|
||||
existing data; new writes follow the new plan.
|
||||
* The feature-flags endpoint keeps publishing the plain edition cap
|
||||
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
|
||||
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
type: concept
|
||||
title: Send-Time Frame Separation (X-Send-Time-Utc)
|
||||
description: One device-clock send timestamp per upload batch splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset — turning clock diagnostics from median statistics into direct per-request measurement.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs
|
||||
tags: [backend, agent, ingest, timestamps, clock-skew, telemetry, provenance]
|
||||
timestamp: 2026-08-21
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Events carry two time anchors: `OccurredUtc` (device clock at emission) and `ReceivedAt`
|
||||
(server clock at ingest). Their difference conflates two physically distinct phenomena:
|
||||
|
||||
* **Spool delay** — the event waited offline or in retry backoff before upload. Expected,
|
||||
harmless, minutes to hours.
|
||||
* **Device-vs-server clock offset** — the actual diagnostic signal.
|
||||
|
||||
A uniform spool backlog (events emitted close together, uploaded after an outage) is
|
||||
indistinguishable from a clock offset in that delta; a draining backlog fakes a clock jump
|
||||
ramp. Every consumer had to filter this statistically (spread caps, plateau guards,
|
||||
persistence tests) — and the first field clock-jump measurement failed exactly on this
|
||||
ambiguity.
|
||||
|
||||
# Schema
|
||||
|
||||
The agent stamps one header per upload attempt (`BackendTelemetryUploader`):
|
||||
|
||||
```
|
||||
X-Send-Time-Utc: <DateTime.UtcNow, ISO-8601 round-trip>
|
||||
```
|
||||
|
||||
Per **attempt**, not per batch content: a retry after backoff is a new send moment. The
|
||||
ingest function parses it (`ParseSendTimeHeader`: round-trip parse, UTC-normalized, values
|
||||
before year 2000 rejected as garbage — deliberately **no upper clamp**, a future-dated
|
||||
send time IS the clock-error measurement), and `StampServerFields` writes it as `SentAt`
|
||||
onto every event of the request — server-stamped, request-level, never trusted per-event
|
||||
from the wire. Stored as the nullable `SentAt` column on Events (absent for pre-header
|
||||
agents), mapped back everywhere events are read, exposed as `sentAt` in the `fields=`
|
||||
projection and verbatim on the raw endpoints.
|
||||
|
||||
The separation is then exact:
|
||||
|
||||
```
|
||||
SentAt − OccurredUtc = pure spool delay (both device frame; offset cancels)
|
||||
ReceivedAt − SentAt = network latency + clock offset (the only frame crossing)
|
||||
```
|
||||
|
||||
Network latency is seconds; anything beyond is device clock error, per request, no
|
||||
statistics required.
|
||||
|
||||
# Consumers
|
||||
|
||||
* **`clock_skew` analyze condition** (ANALYZE-DEV-008): batches whose events carry
|
||||
`SentAt` measure the batch offset directly as `SentAt − ReceivedAt` — spool-immune by
|
||||
construction, so the spread cap and the IME/clamped exclusions are unnecessary there
|
||||
(no event timestamps are read at all; IME-only sessions become measurable). Batches
|
||||
without `SentAt` keep the legacy per-event median + spread-cap path. Downstream logic
|
||||
(stability check, plateau step detection, end-state persistence) is shared; evidence
|
||||
carries `sentAtBatchCount`.
|
||||
* **`CmTraceSkewTripwire` deliberately unchanged**: `median(Δ_IME) − median(Δ_other)`
|
||||
cancels the device clock offset as common mode — send time adds nothing to that
|
||||
differential measurement.
|
||||
|
||||
# Constraints
|
||||
|
||||
* `SentAt` comes from the same device wall clock as the event timestamps — a clock jump
|
||||
between emission and send lands in the spool-delay term. That is the intended place for
|
||||
it to become visible, not a defect.
|
||||
* Signals and DecisionTransitions do not carry `SentAt` (no consumer); the header is
|
||||
request-level, so extending them later is a storage-only change.
|
||||
* Fleet coverage grows with agent rollout; all analyses must treat `SentAt == null`
|
||||
(legacy rows, old agents) as "legacy path", never as zero.
|
||||
|
||||
# Citations
|
||||
|
||||
* `BackendTelemetryUploader.UploadBatchAsync` — header stamping per attempt.
|
||||
* `IngestTelemetryFunction.ParseSendTimeHeader` + `IngestTelemetryFunctionTests` — parse
|
||||
contract incl. the no-upper-clamp decision.
|
||||
* `EventIngestProcessor.StampServerFields` + `IngestCriticalPathTests` — server-side
|
||||
stamping, forged per-event values overridden.
|
||||
* `RuleEngine.ConditionEvaluators.EvaluateClockSkewCondition` + `RuleEngineClockSkewTests`
|
||||
— the two measurement paths (the backlog-alias test pair pins why SentAt exists).
|
||||
* `tasks/todo.md` P14 (2026-08-21) — decision trail incl. why the tripwire stays as is.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Session Annotations — Role-Separated Human Verdicts
|
||||
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- web
|
||||
- mcp
|
||||
- annotations
|
||||
- authorization
|
||||
- rule-quality
|
||||
timestamp: 2026-08-11T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
**Table.** `SessionAnnotations`: PartitionKey = `tenantId`, RowKey = `{sessionId}_{lane}` with
|
||||
lane ∈ `operator | tenantadmin | globaladmin` (a closed set — `AnnotationLanes.All`). One row per
|
||||
session + lane, edited in place; per-session reads use the RK range `ge '{sid}_' / lt '{sid}_~'`.
|
||||
Serialization lives only in `TableSessionAnnotationRepository.StoreAnnotation`/`MapAnnotation`
|
||||
(round-trip-pinned; corrupt `RuleIdsJson` degrades to an empty list on read).
|
||||
|
||||
**Row.** `Verdict` (nullable: `root_cause_confirmed | analysis_wrong | different_problem |
|
||||
inconclusive`), `Note` (nullable, ≤ 4096 chars), `AuthorUpn`/`AuthorDisplayName` (last editor),
|
||||
`CreatedByUpn`/`CreatedAtUtc` (first writer, immutable across edits), `UpdatedAtUtc`, and
|
||||
`RuleIdsJson` — a **snapshot of the rule ids that had fired for the session at write time**
|
||||
(from `IRuleRepository.GetRuleResultsAsync`, fail-soft to empty). The snapshot is what turns an
|
||||
annotation into a rule-quality label without a join: filtering annotations by `ruleId` + `verdict`
|
||||
yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.
|
||||
|
||||
**Verdict + note are independently optional**; a PUT with both null **deletes** the lane row
|
||||
(there is no DELETE route). Author identity is always stamped server-side from the JWT
|
||||
(`TenantHelper.GetUserDisplayName` + `RequestContext.UserPrincipalName`) — a body-supplied
|
||||
author never wins (same anti-spoof contract as the gather-rule PUT-upserts).
|
||||
|
||||
# Authorization
|
||||
|
||||
Routes (all in `EndpointAccessPolicyCatalog`):
|
||||
|
||||
| Route | Policy | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
|
||||
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
|
||||
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
|
||||
|
||||
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
|
||||
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
|
||||
`operator` → tenant Operator or tenant Admin; `tenantadmin` → tenant Admin; `globaladmin` →
|
||||
Global Admin only. **Own-tenant binding:** the session's tenant is resolved *before* the gate
|
||||
(GA cross-tenant fallback via `FindSessionTenantIdAsync`), and tenant-role inputs are zeroed
|
||||
when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of
|
||||
their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a
|
||||
GA writes exactly the `globaladmin` lane (the platform labeling flow).
|
||||
|
||||
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
|
||||
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
|
||||
admins). Both list endpoints enforce the same rule server-side via the repository's
|
||||
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
|
||||
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
|
||||
members of that tenant.
|
||||
|
||||
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
|
||||
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
|
||||
in the tenant-visible audit log — same convention as GA session-report submissions).
|
||||
|
||||
# Lifecycle
|
||||
|
||||
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
|
||||
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
|
||||
steps so the `sessionAnnotations` count covers all lanes.
|
||||
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
|
||||
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
|
||||
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
|
||||
expensive data on the platform to lose.
|
||||
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
|
||||
raw `list_tables`/`query_table` tools.
|
||||
|
||||
# Surfaces
|
||||
|
||||
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
|
||||
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
|
||||
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
|
||||
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
|
||||
count + verdict-pill summary header.
|
||||
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
|
||||
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
|
||||
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
|
||||
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
|
||||
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
|
||||
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
|
||||
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
|
||||
middleware-validated `TargetTenantId`, and for callers without global scope the repository
|
||||
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
|
||||
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
|
||||
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
|
||||
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
|
||||
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
|
||||
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
|
||||
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
|
||||
`tenantId`.
|
||||
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
|
||||
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
|
||||
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
|
||||
applied server-side but client-of-Azure (substring on the JSON column), so the backend
|
||||
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
|
||||
never consume page budget.
|
||||
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
|
||||
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
|
||||
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
|
||||
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
|
||||
Safe because report blobs are readable only through the GA-only session-reports routes, and
|
||||
valuable because the ZIP outlives the session's retention/delete.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
|
||||
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Silent-Session Classification (Sweep, Max-Lifetime, Retro-Reconcile)
|
||||
description: How a session whose agent went silent gets its honest status — the two-stage maintenance sweep (2h Stalled marker, 5h timeout classification), the EnrollmentTimeoutClassifier rule order incl. the WhiteGlove Part-2 and self-deploying gates, which ingest paths share the classifier, and the admin retro-reconcile modes.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
|
||||
tags:
|
||||
- backend
|
||||
- maintenance
|
||||
- session-status
|
||||
- classification
|
||||
- whiteglove
|
||||
- self-deploying
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Silent-Session Classification
|
||||
|
||||
A session only ever becomes terminal through evidence. When the agent stops reporting
|
||||
(power-off, reboot without relaunch, Wi-Fi never re-associating, max-lifetime watchdog),
|
||||
the backend must decide the status from what it last saw — it never hard-fails a session
|
||||
for being silent. One pure function holds that decision,
|
||||
`EnrollmentTimeoutClassifier.ClassifyTimedOutSession`, and every silent-session path calls it
|
||||
so a session gets the same verdict regardless of which path noticed the silence first.
|
||||
|
||||
# Schema
|
||||
|
||||
## Inputs (`EspProvisioningRollup` + session flags)
|
||||
|
||||
`ExtractRollup(events)` distills the event stream into facts: Device/Account Setup
|
||||
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
|
||||
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
|
||||
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
|
||||
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
|
||||
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
|
||||
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
|
||||
|
||||
## Rule order (first match wins)
|
||||
|
||||
| # | Condition | Verdict |
|
||||
| --- | --- | --- |
|
||||
| 1 | `HasExplicitFailure` | Failed |
|
||||
| 1b | `IsWhiteGloveAwaitingUser`: pre-provisioned + resumed + Device Setup done + **no** user evidence since resume + no explicit failure/enrollment_complete | AwaitingUser within grace; Succeeded (honest WG reason) past grace |
|
||||
| 1c | `IsSelfDeployingProvisioned`: self-deploying profile + Device Setup done + no explicit failure | **Succeeded** (reconcile) — no grace, no AwaitingUser: the profile has no user phase |
|
||||
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
|
||||
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
|
||||
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
|
||||
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
|
||||
| 6 | otherwise | Incomplete |
|
||||
|
||||
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
|
||||
measured from `ResumedAt ?? StartedAt`. Every Succeeded-reconcile reason carries the
|
||||
silence-transparency clause (`AppendReconcileTiming`: last agent contact, silence, verdict time).
|
||||
|
||||
## Why rule 1c exists (kiosk tenant audit 2026-08-23)
|
||||
|
||||
On a self-deploying profile Device ESP all-succeeded IS the enrollment's end; the agent's
|
||||
own SelfDeploying terminal only adds a 5-min confirmation window
|
||||
(`DecisionEngine.SelfDeploying.s_deviceOnlyEspDetectionWindow`). Tenant `aebdce78` (school,
|
||||
Wi-Fi ThinkBooks, a language-change app forcing a reboot right after Device ESP) lost that
|
||||
window on 836 of ~970 sessions in one week: the agent died 0–4 min after
|
||||
`DeviceSetup fallback_confirmed`, the reboot relaunch never registered (30s registration
|
||||
budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
|
||||
"awaiting user / Account Setup 0/5" → Incomplete 51h later. Nobody signs in on these devices
|
||||
(prepared during the holidays, boxed, in service) — AwaitingUser was factually wrong and
|
||||
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
|
||||
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
|
||||
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
|
||||
## Callers (all pass both gate inputs)
|
||||
|
||||
| Path | File | Notes |
|
||||
| --- | --- | --- |
|
||||
| Sweep stage 1 (2h agent-silent marker) | `MaintenanceService.MarkStalledSessionsAsTimedOutAsync` | Default: InProgress → Stalled. Gates run BEFORE the Stalled write: `TryMarkWhiteGloveAwaitingUserAsync` (AwaitingUser) and `TryReconcileSelfDeployingAsync` (Succeeded). Only flagged sessions pay the event read. |
|
||||
| Sweep stage 2 (SessionTimeoutHours, 5h default) | same | InProgress/Stalled/AwaitingUser past the window and past the 2h silence cutoff → full classification; AwaitingUser within grace is skipped without an event read. |
|
||||
| Agent max-lifetime (`enrollment_failed`/max_lifetime, `agent_shutting_down`/max_lifetime) | `EventIngestProcessor.ApplyMaxLifetimeVerdictAsync` | Watchdog = "agent stopped waiting", not a verdict; classified from evidence. Pending sessions exempt. |
|
||||
| Late-telemetry reconcile | `EventIngestProcessor.TryLateTelemetryReconcileAsync` | Heal-only: applies a Succeeded verdict to Incomplete/AwaitingUser rows when straggler events arrive. |
|
||||
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
|
||||
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
|
||||
|
||||
## Known gap (decided, not built)
|
||||
|
||||
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
|
||||
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
|
||||
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
|
||||
through the grace window by design ("user simply absent"). Revisit if customers read
|
||||
AwaitingUser as "still possible".
|
||||
|
||||
# Examples
|
||||
|
||||
* Session `195593e2` (tenant aebdce78): `DeviceSetup 4/4` + `fallback_confirmed`, `AccountSetup 0/5`
|
||||
(IME false positive), Hello disabled, SkipUser=True, silent after 13:01 → rule 1c →
|
||||
Succeeded "self-deploying profile — Device Setup fully provisioned … agent went silent
|
||||
before confirming completion. Agent last reported 2026-08-21 13:01 UTC …".
|
||||
* Same event shape on a user-driven profile → rule 5 → AwaitingUser (within grace) /
|
||||
Incomplete (past grace). Guarded by
|
||||
`Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts`.
|
||||
* fairstone WG Part 2 reseal-reboot, technician powers off at logon screen → rule 1b →
|
||||
AwaitingUser; user unboxes days later → ingest completion heals to Succeeded.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs` — rollup + rule order, `IsWhiteGloveAwaitingUser`, `IsSelfDeployingProvisioned`, reason helpers.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — `MarkStalledSessionsAsTimedOutAsync`, `TryMarkWhiteGloveAwaitingUserAsync`, `TryReconcileSelfDeployingAsync`.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs` — max-lifetime verdict, late-telemetry reconcile, stall mapping.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs`, `Functions/Admin/ReclassifyLegacySessionsFunction.cs` — retro modes.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs` — `GetAgentSilentSessionsAsync`, `GetStalledSessionsAsync`, `GetSelfDeployingSilentSessionsAsync`.
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — link wait + 6-attempt registration budget.
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs` — the agent-side 5-min SelfDeploying terminal window.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs` — rule coverage incl. the 2026-08-23 self-deploying block.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Table Schema Sentinel (startup table initialization)
|
||||
description: Why a cold start no longer issues one CreateTableIfNotExists per registered table - a hash over TableNames.All stored in AdminConfiguration gates the full pass, the SessionsIndex backfill only runs after a full pass and behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
|
||||
tags:
|
||||
- backend
|
||||
- table-storage
|
||||
- startup
|
||||
- maintenance
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Problem
|
||||
|
||||
`TableInitializerService` (hosted service) called `InitializeTablesAsync` on every process start, which issued `CreateTableIfNotExistsAsync` for every entry of `Constants.TableNames.All` (66 at the time of writing, DoP 8). Each call is a Create POST whose 409 is swallowed - on Flex Consumption with frequent cold starts that was ~9 serial round-trip waves before the first request. It was followed by a SessionsIndex emptiness query whose *error* path returned "empty", so a transient storage error triggered a full Sessions scan with serial upserts - on every scaled-out instance at once.
|
||||
|
||||
## Sentinel
|
||||
|
||||
* Row `AdminConfiguration` / PK `SchemaSentinel` / RK `tables`, property `TableSchemaHash` (+ `TableCount`, `UpdatedUtc`).
|
||||
* Value = `ComputeTableSchemaHash(TableNames.All)`: SHA-256 over the ordinal-sorted, newline-joined table names. Derived, never hand-maintained: adding a table to `TableNames.All` changes the hash by construction (`TableLifecycleBucketTests` already forces every constant into `All`).
|
||||
* `InitializeTablesAsync` (returns `bool fullPassRan`):
|
||||
1. Point-read the sentinel. Match -> mark initialized, **zero** table calls, return `false`.
|
||||
2. Missing / stale / unreadable (fresh storage has no AdminConfiguration table yet) -> `EnsureAllTablesAsync` (the old full pass), then upsert the sentinel **only if no table failed**, return `true`. A failed pass leaves `_tablesInitialized = false`, so the next call retries.
|
||||
* Scale-out: two instances that both see a stale sentinel both run the idempotent pass - harmless.
|
||||
|
||||
## SessionsIndex backfill
|
||||
|
||||
* Only evaluated when the full pass ran (`fullPassRan == true`): an empty index can only exist on fresh storage, which is exactly the case without a sentinel.
|
||||
* `IsSessionIndexEmptyAsync` now returns `false` on error (skip), not `true`.
|
||||
* `TryClaimSessionIndexBackfillAsync`: conditional `AddEntityAsync` on `SchemaSentinel` / `sessionIndexBackfill` - only the winner scans; 409 or any error -> skip. The manual maintenance backfill (`RunManualAsync`) remains the safety net.
|
||||
|
||||
## Known gap and repair
|
||||
|
||||
A table deleted out-of-band (cleanup skill, portal, tests) is not noticed at startup anymore. `IStorageInitializer.EnsureAllAsync` runs as the first step of the daily timer maintenance (`MaintenanceService.RunAllAsync`), recreating it within 24h. Storage helpers are fail-soft in the meantime.
|
||||
|
||||
## Measuring it
|
||||
|
||||
`StartupTelemetryService` (hosted service) emits two App Insights metrics on `IHostApplicationLifetime.ApplicationStarted` - i.e. after every hosted service finished `StartAsync`:
|
||||
|
||||
* `BackendStartupMs` - process start (`Process.StartTime`) to host ready; includes runtime, DI and all hosted services.
|
||||
* `BackendTableInitMs` - the `InitializeTablesAsync` slice alone (`TableStorageService.LastInitialization`).
|
||||
|
||||
Dimensions: `version`, `commit`, `tableInitFullPass`, `instance`. Metrics bypass adaptive sampling and the worker log level, so they arrive even though Information traces do not. The MCP server logs the same idea as one `{"type":"boot","readyMs":…}` stderr line per start (ContainerAppConsoleLogs_CL, see `.claude/commands/mcp-telemetry.md`).
|
||||
|
||||
```kusto
|
||||
customMetrics
|
||||
| where name in ("BackendStartupMs", "BackendTableInitMs")
|
||||
| summarize p50 = percentile(value, 50), p95 = percentile(value, 95), n = count()
|
||||
by name, version = tostring(customDimensions.version), fullPass = tostring(customDimensions.tableInitFullPass)
|
||||
```
|
||||
|
||||
# Examples
|
||||
|
||||
* Deploy that adds a table: first cold start after the deploy runs the full pass once (log: "Table schema sentinel missing or stale"), every later start logs "Table schema sentinel matches (...)".
|
||||
* Fresh storage account: sentinel read throws TableNotFound -> full pass -> index empty -> one instance claims and backfills.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs` - sentinel, `InitializeTablesAsync`, `EnsureAllTablesAsync`, `TryClaimSessionIndexBackfillAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs` - startup gating
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` - daily `EnsureAllTablesAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/StartupTelemetryService.cs` - `BackendStartupMs` / `BackendTableInitMs`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/index.ts` - `{"type":"boot"}` line
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TableSchemaSentinelTests.cs`, `StartupTelemetryServiceTests.cs`
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Telemetry Ingest Shaping (App Insights / Log Analytics)
|
||||
description: What the backend deliberately keeps out of Application Insights and why - one canonical request record (worker copy, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation), expected storage outcomes (404/412/409) and successful SignalR REST calls filtered in the worker, and the query rules that follow.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs
|
||||
tags:
|
||||
- backend
|
||||
- telemetry
|
||||
- application-insights
|
||||
- cost
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Telemetry Ingest Shaping
|
||||
|
||||
The backend is storage-I/O heavy and runs on the Functions isolated worker, which emits telemetry from TWO processes (host and worker). Left alone, Log Analytics ingestion is dominated by rows nobody reads. This document records what is removed, where, and what that means for queries.
|
||||
|
||||
# Schema
|
||||
|
||||
## Request telemetry — one canonical record
|
||||
|
||||
| Source | Emitted by | Content | Fate |
|
||||
|---|---|---|---|
|
||||
| Host HTTP request (`Properties.Source` empty, `Url` set) | Functions host process | Name, duration, status — no tenant/user/correlation | **Dropped at ingestion** by the workspace transformation on `AppRequests` (`infra/appinsights-workspace-transforms.bicep`) |
|
||||
| Host non-HTTP invocation (`Url` empty) | Functions host process | Timer / queue executions | Kept (the worker middleware never sees them) |
|
||||
| Worker request (`Properties.Source == 'WorkerMiddleware'`) | `RequestTelemetryMiddleware` | TenantId, UserId, UserRole, CorrelationId, ClientSource, McpToolName, HttpPath | **Kept, unsampled** — the canonical record |
|
||||
|
||||
Why two mechanisms:
|
||||
|
||||
* The host request item cannot be suppressed from worker code or from `host.json` (`Host.Results` controls log traces, not the request item; verified 2026-06-09). The only lever is a Log Analytics workspace transformation: `source | where tostring(Properties.Source) == 'WorkerMiddleware' or isempty(Url)`.
|
||||
* `host.json` `samplingSettings.excludedTypes: "Request;Event"` only applies to the host process. The worker's `AddApplicationInsightsTelemetryWorkerService` runs its own adaptive sampling; before 2026-08-23 worker request rows carried `ItemCount` 2–5 on weekdays (≈30 % of individual requests missing, `count()` undercounting by the same share — `sum(ItemCount)` matched the host copy to 1.00 per day). Re-configuring the worker sampling chain did not take effect in the isolated worker (see the L4 note in `Program.cs`). The per-item opt-out does: the middleware sets `SamplingPercentage = 100` on its own `RequestTelemetry`, and the SDK sampling processor passes through every item whose sampling percentage is already set.
|
||||
|
||||
## Dependency telemetry — keep only signal
|
||||
|
||||
`StorageDependencyFilterProcessor` (worker pipeline) drops:
|
||||
|
||||
* successful Azure Storage calls (Table/Queue/Blob) in both shapes — the HTTP shape (`Azure table`, target `*.table.core.windows.net`) and the Azure SDK ActivitySource shape (`InProc | Microsoft.Tables`, `InProc | Microsoft.Storage`);
|
||||
* storage calls with an **expected outcome**: `404` (point-read miss — EventTypeIndex, BlockedDevices lookups), `412` (ETag precondition), `409` (idempotent insert conflict). HTTP-shape rows carry the code in `ResultCode`; InProc rows only in `Properties["Error"]` (`Status: 404 (`). Before this filter these were 100 % of the remaining "failed" storage rows (~540 MB/week, two rows per call);
|
||||
* successful Azure SignalR Service REST calls the worker makes itself (`*.service.signalr.net`).
|
||||
|
||||
The bulk of SignalR REST traffic (group add/remove per connection, ~470 MB/week) is emitted by the **host** process for the SignalR output binding (`SDKVersion rdddsc:*`) and never passes the worker pipeline — live-verified after the 2026-08-23 deploy (worker rows: 0, host rows: ~560/h). Those are removed by the second data flow of the same workspace transformation: `AppDependencies | where not(Success == true and Target endswith '.service.signalr.net')`.
|
||||
|
||||
Everything else passes: storage 429/5xx/auth/timeouts, failed SignalR calls, all HTTP/Graph/NVD/MSRC dependencies, the worker's own `InProc Invoke` span, and every non-dependency item.
|
||||
|
||||
## Measured baseline (7 days before the change, `_BilledSize`, `autopilotmonitor-api-eu`)
|
||||
|
||||
| Table | Rows | MB | After |
|
||||
|---|---|---|---|
|
||||
| AppDependencies | 976k | 1248 | ≈ 240 (SignalR + expected-outcome rows gone) |
|
||||
| AppRequests host copy | 274k | 323 | ≈ 0 (only timer/queue rows) |
|
||||
| AppRequests worker copy | 189k | 208 | ≈ 300 (now 100 % of requests) |
|
||||
| AppTraces | 142k | 137 | unchanged |
|
||||
| AppEvents | 6k | 4 | unchanged — not worth touching |
|
||||
|
||||
Net ≈ −1.3 GB/week with MORE usable data (every request now has tenant context).
|
||||
|
||||
# Examples
|
||||
|
||||
Counting requests (exact since 2026-08-23; for older data replace `count()` with `sum(itemCount)`):
|
||||
|
||||
```kql
|
||||
requests
|
||||
| where customDimensions.Source == 'WorkerMiddleware'
|
||||
| summarize total=count(), failed=countif(success == false) by name
|
||||
```
|
||||
|
||||
Real storage trouble only:
|
||||
|
||||
```kql
|
||||
dependencies
|
||||
| where type startswith 'InProc | Microsoft.Tables' or target endswith '.table.core.windows.net'
|
||||
| summarize count() by resultCode, tostring(customDimensions.Error)
|
||||
```
|
||||
|
||||
Post-deploy verification of the per-item sampling bypass (must be `ItemCount == 1` only):
|
||||
|
||||
```kql
|
||||
AppRequests
|
||||
| where TimeGenerated > ago(1h) and tostring(Properties.Source) == 'WorkerMiddleware'
|
||||
| summarize rows=count() by ItemCount
|
||||
```
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `SamplingPercentage = 100` before `TrackRequest`.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs` + `StorageDependencyFilterProcessorTests.cs` — the filter contract.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Program.cs` — the failed L4 worker-sampling attempt and why it must not be retried.
|
||||
* `infra/appinsights-workspace-transforms.bicep` — the AppRequests transformation DCR, apply/verify/rollback commands.
|
||||
* `.claude/commands/backend-logs.md`, `session-debug.md` — query conventions.
|
||||
@@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -58,6 +58,46 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
## Mail transport
|
||||
|
||||
Both transactional mails (welcome, post-offboarding farewell) go through the
|
||||
provider-neutral `EmailService` (`IEmailService` for the welcome path,
|
||||
`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation
|
||||
detail confined to one private transport method plus the `Email:*` configuration
|
||||
section — a provider swap must not rename classes, DI registrations or callers.
|
||||
|
||||
* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}`
|
||||
(`messages/send`), API key in the JSON body, typed `HttpClient` with the shared
|
||||
`ResiliencePolicies.Notification` retry policy (15 s timeout).
|
||||
* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every
|
||||
send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress`
|
||||
(default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`).
|
||||
* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success;
|
||||
`rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning.
|
||||
Never throws — both callers rely on that fail-soft contract.
|
||||
* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider
|
||||
receives only the recipient address and the tenant domain, which is exactly the claim on
|
||||
the privacy page and the trust data-flows page. Changing tracking = changing those pages.
|
||||
|
||||
## Template overrides (operator-editable)
|
||||
|
||||
Global Admins can replace either HTML body without a deployment (portal: Settings →
|
||||
Email Templates). `EmailTemplateService` resolves the body for every send: the stored
|
||||
override wins, otherwise the built-in `EmailTemplates.*`. Overrides live in the
|
||||
`PreviewConfig` table (partition `EmailTemplates`, row key `welcome` | `farewell`,
|
||||
properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on save/reset.
|
||||
|
||||
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
|
||||
matching the built-ins. Subjects are not editable (pinned constants).
|
||||
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
|
||||
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
|
||||
read path is not — a broken store surfaces as an error instead of silently showing the
|
||||
built-in.
|
||||
* Endpoints (all GlobalAdminOnly): `GET|PUT|DELETE /api/global/email-templates/{kind}` and
|
||||
`POST /api/global/email-templates/{kind}/test`. The test send goes to the caller's tenant
|
||||
contact address (activation notification email → `ContactEmail` → caller UPN) with the
|
||||
caller's tenant domain, optionally with an unsaved draft body; provider tag `test`.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
@@ -73,3 +113,8 @@ paths never duplicate afterwards.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
|
||||
|
||||
@@ -58,6 +58,21 @@ timestamp runs backward are dropped (`ClockSkewDropped`).
|
||||
| Complete, `desktop_arrived` event | `desktop_handoff` |
|
||||
| Failed | attribution ends — tail stays unattributed |
|
||||
|
||||
## Quality flags (AttributionVersion 2)
|
||||
|
||||
Duration-critical flags (`ClockSkewDropped`, `PartialObservation`,
|
||||
`WhiteGloveAnchorsIncomplete`, `PriorEnrollmentResidue`) exclude a breakdown from fleet
|
||||
segment statistics with a disclosed count; the blocking-set flags (`BlockingSetUnknown`,
|
||||
`BlockingSetTruncated`) only limit per-app evidence and never gate fleet stats.
|
||||
|
||||
`PriorEnrollmentResidue` (v2) fires on `historic_ime_replay_detected`: the IME log on disk
|
||||
predates the enrollment, so the device was re-enrolled without a wipe — pre-installed apps
|
||||
complete as instant detections and pull the phase anchors ahead of the real ESP page
|
||||
(session f475e697: AccountSetup anchor 6.5 min early, 457 s misattributed to
|
||||
`identity_hello`). `registry_app_baseline` successes are deliberately NOT a trigger — they
|
||||
are by-design normal for Windows Device Preparation (DPP Batch-1 apps install before the
|
||||
agent exists) and would starve that class's aggregates.
|
||||
|
||||
## ESP-blocking apps (positive evidence only)
|
||||
|
||||
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
|
||||
@@ -90,6 +105,11 @@ the partition).
|
||||
* Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds
|
||||
late-terminating sessions into their StartedAt-date aggregates, and enforces version
|
||||
purity — a stale-version row whose events aged out counts as missing, never mixed.
|
||||
Reads the tick's **shared window scan** (`MaintenanceService.LoadSweepWindowSessionsAsync`,
|
||||
one projected 35-day cross-tenant drain via `MaintenanceSweepSessionProjection` that
|
||||
also feeds the device-journey and verdict-calibration sweeps) — the StartedAt-only
|
||||
filter is a full-table read in Table Storage, so it is done once per tick, not once per
|
||||
sweep; `MaintenanceSweepProjectionEquivalenceTests` pins the column set.
|
||||
* `TimeAttributionAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}|{class}` and
|
||||
`rolling30|{class}`): per enrollment class (`user_driven`, `whiteglove`, `self_deploying`,
|
||||
`device_preparation` — never mixed) median/p75/p90 per segment over the fixed six-segment
|
||||
@@ -117,7 +137,7 @@ gap before the first part-2 phase declaration is unattributed, not guessed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 2).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Verdict Calibration (classifier thermometer)
|
||||
description: How every session status write declares its VerdictPath, how overriding a verdict preserves it as PriorStatus/PriorVerdictPath, how the daily calibration aggregate counts paths with the re-enrollment proxy and the correction stream, and how the drift radar (share / silence / evidence-gap) turns that into operator-only ops events.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.VerdictCalibration.cs
|
||||
tags:
|
||||
- backend
|
||||
- sessions
|
||||
- classifier
|
||||
- calibration
|
||||
- regression-radar
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Verdict Calibration (classifier thermometer)
|
||||
|
||||
A session's terminal status is written by ~20 code paths: the agent's own terminal events,
|
||||
the silence classifier (`EnrollmentTimeoutClassifier`, rules 1–6) reached via the
|
||||
maintenance sweep / the agent's max-lifetime shutdown / late-telemetry heal / retro
|
||||
reclassification, the analyze-rule engine, admin marks and session superseding. Verdict
|
||||
calibration makes every one of those paths **countable**, tracks how often a path's verdict
|
||||
was later **overridden**, attaches the delayed **re-enrollment proxy**, and raises an
|
||||
operator-only alarm when a path's share **drifts**. It is deterministic, fact-based tuning
|
||||
input for the rules — explicitly not a learned model, and never a customer-facing feature.
|
||||
|
||||
# Schema
|
||||
|
||||
## VerdictPath on the session row
|
||||
|
||||
`SessionSummary.VerdictPath` (`VerdictPaths` vocabulary, `origin:detail`, append-only) is
|
||||
stamped by **every** status write — `UpdateSessionStatusAsync` takes it as a required
|
||||
parameter, `StoreSessionAsync` stamps `register:new` / `register:whiteglove_resume` and
|
||||
preserves an existing path through the re-registration Replace, `SetSessionPreProvisionedAsync`
|
||||
requires it whenever it writes a status. Unlike `FailureSource` (persisted only on `Failed`),
|
||||
the path survives for Incomplete, AwaitingUser, Stalled and Pending.
|
||||
|
||||
| Origin | Paths |
|
||||
| --- | --- |
|
||||
| `agent` | `complete`, `complete_soft` (Continue-Anyway), `failed`, `esp_failure_fallback`, `gather_complete`, `whiteglove_pending`, `whiteglove_resumed`, `stall_probe`, `stall_heal` |
|
||||
| `ingest` | `wg_awaiting` (stall probe mapped to WhiteGlove AwaitingUser) |
|
||||
| `sweep` | `stalled`, `wg_awaiting`, `sd_reconcile`, `r{rule}` (stage-2 classifier) |
|
||||
| `maxlife` / `late` / `retro` | `r{rule}` (classifier via max-lifetime shutdown / late-telemetry heal / retro reclassification); `retro:sd_reconcile`, `retro:superseded` |
|
||||
| `register` | `new`, `whiteglove_resume`, `superseded` |
|
||||
| `rule` | `rule:{ruleId}` (analyze rule with MarkSessionAsFailed) |
|
||||
| `manual` | `failed`, `succeeded` |
|
||||
| `legacy` | read-side derivation only (`legacy:{rule}` when the rule is recoverable from the reason literal, `legacy:unknown` otherwise) — never written |
|
||||
|
||||
Classifier rule ids (`ClassifierRules`): `r1`, `r1b_awaiting`, `r1b_succeeded`, `r1c`, `r2`,
|
||||
`r3`, `r4`, `r5_awaiting`, `r5_incomplete`, `r6`. `ClassifyTimedOutSession` returns
|
||||
`(Status, Reason, Rule)` so each `return` names its rule.
|
||||
|
||||
## PriorStatus / PriorVerdictPath — the correction stream
|
||||
|
||||
When a write replaces a prior **verdict** (existing status ∈ Succeeded/Failed/Incomplete/
|
||||
AwaitingUser with a stamped path, incoming status different), the storage seam preserves the
|
||||
pair (`TableStorageService.ComputePriorVerdict`, both the ETag path and the force path). A
|
||||
stall marker healing to InProgress, a same-status refresh, or an unstamped legacy row never
|
||||
sets it. Admin marks, late agent completions and retro reclassifications all flow through it.
|
||||
|
||||
## Read-side derivation for pre-instrumentation rows
|
||||
|
||||
`VerdictPathDerivation.Derive(session)` returns the stamped path verbatim, otherwise derives
|
||||
from `AdminMarkedAction`, `FailureSource`, `ReconcileReason` and `FailureReason` using the
|
||||
writers' fixed literals (classifier rule prefixes, the `max-lifetime watchdog` / `Retro-
|
||||
reclassified` suffixes, `Rule: `, `Superseded by session`, `Late completion report received`).
|
||||
Unambiguous rows get the real path (`agent:complete`, `manual:failed`, `rule:…`,
|
||||
`maxlife:r6`), ambiguous-but-rule-known rows `legacy:{rule}`, the rest `legacy:unknown`. No
|
||||
mass backfill: the aggregate counts derived rows with `DerivedCount` so the matrix shows
|
||||
history while flagging its weaker evidence. Tests pin the literals against the classifier
|
||||
output so a wording change fails loudly.
|
||||
|
||||
## Daily aggregate — `VerdictCalibrationAggregates`
|
||||
|
||||
PK = `TenantId` | `"global"`, RK = `yyyy-MM-dd` (session **StartedAt** date, like every daily
|
||||
aggregate). `VerdictCalibrationDailyAggregate { Version, SessionCount, TerminalSessionCount,
|
||||
Buckets[], ComputedAt }` with one bucket per (VerdictPath, Status):
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `Count` / `DerivedCount` | sessions currently on the path; of those, derived read-side |
|
||||
| `Eligible7d` | terminal sessions whose end (`CompletedAt ?? LastEventAt ?? StartedAt`) lies ≥ 7 d before compute time |
|
||||
| `ReEnrolled7d` | of the eligible: the device's `DeviceHistories` chain shows the next terminal session starting < 7 d after this one's end |
|
||||
| `OverriddenByAdmin` / `OverriddenByLateCompletion` / `OverriddenOther` | sessions whose **PriorVerdictPath** is this bucket — attributed to the path that was overridden, so a bucket can carry overrides with `Count = 0` |
|
||||
|
||||
`MaintenanceService.SweepVerdictCalibrationAsync` (2-hourly, after the device-journey sweep
|
||||
so the chains are fresh; manual maintenance parity; kill switch
|
||||
`VerdictCalibrationSweepDisabled`) slices the tick's shared projected window scan
|
||||
(`LoadSweepWindowSessionsAsync` — one 35-day cross-tenant drain feeds the time-attribution,
|
||||
device-journey and calibration sweeps plus the radar's tenant discovery; the StartedAt-only
|
||||
filter is a full-table read, so never per sweep), recomputes the rolling 30-day window whole (Replace),
|
||||
deletes stale date rows it did not regenerate, and the unbounded-tables cleanup applies the
|
||||
180-day retention. Sessions inside a deletion cascade are skipped; the table is regenerable
|
||||
and is wiped with the tenant on offboarding.
|
||||
|
||||
## Read surfaces
|
||||
|
||||
* `GET global/metrics/verdict-calibration?days=&tenantId=` (GlobalReadOrAdmin,
|
||||
TenantScoping.QueryParam; `VerdictCalibrationResponse.Build`): per path — count, share of
|
||||
all window sessions, derived count, eligible/re-enrolled with `reEnrollRatePct` **null below
|
||||
20 eligible**, the three override counters, a today-anchored 7d-vs-28d share trend
|
||||
(`window7`, `baseline28`, `lift` null without baseline) — plus totals, the trend basis and
|
||||
the active `alerts[]`. Empty but well-formed before the first sweep.
|
||||
* MCP `get_verdict_calibration` (platform scope: GA + Global Reader). Same endpoint, LLM-shaped in
|
||||
`verdict-calibration-shape.ts`: per-row `window7.sessions` / `baseline28.sessions` are dropped (the
|
||||
denominators live once in `trend`), `reEnrollRatePct` / `lift` are always present (`null` = withheld),
|
||||
and `minSharePct` / `top` trim the one-session tail — rows carrying overrides are never trimmed and
|
||||
everything dropped is reported in `omitted`. The wire shape the admin page consumes is untouched.
|
||||
* Portal: Global Admin → Metrics → Verdict Calibration (`/admin/metrics/verdict-calibration`):
|
||||
matrix grouped by origin, status pill, `derived n` gray pill, trend glyph (arrow only from
|
||||
5 window hits and lift ≥ 2 / ≤ 0.5), re-enrollment rate or `— (n=…)`, overrides
|
||||
`admin / late / other`, `↑ Drift` pill on rows with an active episode, and the active
|
||||
episodes list.
|
||||
|
||||
## Drift radar — `VerdictCalibrationRadar`
|
||||
|
||||
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
|
||||
prior 28 days, ≥ 5 window hits, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.
|
||||
RateIncreaseSeparated`), anchored on yesterday, over every partition with rows (each tenant
|
||||
and `global`):
|
||||
|
||||
| Kind | Signal | Re-arm |
|
||||
| --- | --- | --- |
|
||||
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded) | share < 1.5× baseline, the path stops occurring, or the path is no longer eligible |
|
||||
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one) | same |
|
||||
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts, ≥ 20 verdicts | < 15 % or < 20 verdicts |
|
||||
|
||||
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
|
||||
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
|
||||
retention re-arms long burners). A new episode fires **one** `VerdictCalibrationDrift` ops
|
||||
event (category Maintenance, Warning; dual-registered in the web `OPS_EVENT_TYPES`) — there
|
||||
is deliberately **no tenant bell**. Per-path findings on a tenant partition get the on-fire
|
||||
dimension correlation (`RuleRegressionRadar.ComputeDimensionConcentration` over the window's
|
||||
sessions whose derived path matches), reported with the shared wording contract "correlated,
|
||||
not necessarily causal"; group kinds and the global partition carry no dimension claim.
|
||||
Kill switch `VerdictCalibrationRadarDisabled`.
|
||||
|
||||
# Examples
|
||||
|
||||
* **Reading the thermometer (prod, 2026-08-23 hand analysis that motivated this):**
|
||||
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
|
||||
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
|
||||
cautious; the matrix now shows this per rule instead of per status.
|
||||
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
|
||||
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
|
||||
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
|
||||
Four admin marks since launch were invisible before — now each one counts against the path
|
||||
it corrected.
|
||||
* **Evidence gap:** a tenant whose agent build stopped emitting the Device Setup rollup line
|
||||
drives its sweep verdicts into `r6`; the absolute gate fires at 20 % and names the
|
||||
tenant — the fix is agent-side, the classifier is only the messenger.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/VerdictPaths.cs` — vocabulary + `ClassifierRules`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `ComputePriorVerdict`, `ApplyVerdictPath`, re-registration preserve
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/VerdictPathDerivation.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.VerdictCalibration.cs` — sweep + `BuildVerdictCalibrationAggregates`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/VerdictCalibrationRadar.cs`, `Services/MaintenanceService.VerdictCalibrationRadar.cs`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `get_verdict_calibration`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts` — LLM-facing response shaping (`minSharePct` / `top`, explicit nulls)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx`
|
||||
* Tests: `VerdictPathTests`, `VerdictCalibrationTests`, `VerdictCalibrationRadarTests`
|
||||
* Related: [Silent-Session Classification](silent-session-classification.md), [Device Journeys](device-journeys.md), [Rule Regression Radar](rule-regression-radar.md)
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
|
||||
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
|
||||
tags:
|
||||
- contracts
|
||||
- guard-tests
|
||||
- signalr
|
||||
- web
|
||||
timestamp: 2026-08-13T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Drift Guards
|
||||
|
||||
Follow-up to the 2026-08 fragility audit (see [Lifecycle Manifests & Session Scope
|
||||
Resolution](backend/lifecycle-manifests-and-session-scope.md) for the earlier rounds).
|
||||
Each section below replaces a hand-synchronized mirror with an enforced contract: a
|
||||
change on one side now fails a build, a type check, or a test on the other side.
|
||||
|
||||
# Schema
|
||||
|
||||
## SignalR message names — one catalog, typed on both ends
|
||||
|
||||
`Constants.SignalRMessages` (Shared) is the complete catalog of SignalR target names.
|
||||
Backend senders (output bindings and the imperative `SignalRNotificationService`)
|
||||
reference the constants; the hub name is `SignalRGroupHelper.HubName`. The catalog is
|
||||
exported into `shared-manifests.json` (`signalRMessages` section) by
|
||||
`SharedManifestParityTests`, and the web's `SignalRContext.on/off` type their event
|
||||
name against the generated union (`lib/signalrMessages.ts`) — subscribing to a name
|
||||
the backend never sends is a tsc error, not a silent no-op. `newevents` is the one
|
||||
legacy lowercase name; it is a persisted wire name, do not "fix" the casing.
|
||||
|
||||
## Inverted-tick RowKeys — one codec
|
||||
|
||||
`RowKeyCodec` (Functions/Helpers) is the single encoder/decoder for
|
||||
`rev(t) = MaxTicks - t.Ticks` RowKeys. Every writer routes through it; the persisted
|
||||
per-table shapes (D19 standard, AuditLogs `!`-prefix, UserActivity's legacy D20 width,
|
||||
ConfigurationBackups' 28-char truncation) are pinned by `RowKeyCodecTests` and must
|
||||
never change — a width/prefix change reorders live tables against their existing rows.
|
||||
`BusinessTimestamp` keeps the OData clause builders and delegates decode to the codec.
|
||||
|
||||
## Tombstone rows carry their table
|
||||
|
||||
`DeletionRowDump.Table` names the source table on FINAL (Tombstone) rows — the one
|
||||
step that mixes two tables while `DeletionStep.Table` is null.
|
||||
`DeletionTombstoneTables.Resolve` is the only consumer-side resolution (delete +
|
||||
restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic survives
|
||||
solely as the fallback for manifests written before the field existed and must outlive
|
||||
one full manifest-retention cycle.
|
||||
|
||||
## Anonymous OkAsync responses — frozen, shrink-only
|
||||
|
||||
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
|
||||
sites as a per-file baseline. A new anonymous response (new file or count above
|
||||
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
|
||||
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
|
||||
list stays truthful and only ever shrinks.
|
||||
|
||||
## Tenant settings — per-section PATCH
|
||||
|
||||
The web's Settings sections no longer PUT the full ~92-field configuration. Each
|
||||
section's exact write surface lives in `app/settings/sectionFieldMap.ts` (owned fields
|
||||
plus documented `alsoWrites` write-throughs); `saveConfiguration` diffs those fields
|
||||
against the loaded config and PATCHes only the changes through
|
||||
`PATCH config/{tenantId}/fields` — the transactional endpoint (CAS + fail-closed
|
||||
backup + exactly-these-fields verify + auto-rollback) that previously served MCP only.
|
||||
The policy line is TenantAdminOrGA; a tenant admin runs on the stricter
|
||||
`TenantConfigCallerTier.TenantAdmin` whose deny-list turns GA-only fields into
|
||||
explicit 400s. Deploy order: backend before web. `sectionFieldMap.test.ts` pins
|
||||
field↔section ownership (each field exactly one owner, all fields on the model, none
|
||||
server-denied); backups/revert stay GlobalAdminOnly.
|
||||
|
||||
## Scoped endpoint routing — one decision object
|
||||
|
||||
`lib/scopedApi.ts` owns the tenant/global endpoint-pair choice: pages pass their scope
|
||||
hook object (`routeGlobal`, `selectedTenantId`, `effectiveTenantId`) instead of
|
||||
hand-rolling `routeGlobal ? global(..., sel || undefined) : tenant(tid, ...)` with the
|
||||
tenant parameter at a different position per pair. `useGlobalAdminScope` is now a thin
|
||||
projection over `useAggregatedAdminScope` (one hook, aggregated as a mode) via the
|
||||
pure `resolveConcreteScopeView`. `lib/scopedFetch.ts` adds the shared JSON+ok-check
|
||||
fetch layer. `lib/navVisibility.ts` extracts the sidebar's roles × nav-config × guard
|
||||
logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it"
|
||||
mismatches (plain Operator and GlobalReader vs. `/settings` — pending a user decision).
|
||||
|
||||
# Examples
|
||||
|
||||
Renaming a SignalR message end to end: change the constant in
|
||||
`Constants.SignalRMessages` → `AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter
|
||||
SharedManifestParityTests` → `node scripts/generate-shared-manifest-types.js` → tsc
|
||||
now flags every stale web subscription literal.
|
||||
|
||||
Adding a field to a Settings section: add it to the section in `sectionFieldMap.ts`
|
||||
(tsc validates the name against the manifest), wire the form state into the
|
||||
`updatedConfig` construction in `TenantConfigContext`, and extend the expected-fields
|
||||
list in `sectionFieldMap.test.ts`.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
|
||||
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
|
||||
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
|
||||
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
|
||||
+23
-1
@@ -13,17 +13,26 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
|
||||
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
|
||||
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
|
||||
# Agent
|
||||
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
|
||||
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
|
||||
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
@@ -31,17 +40,29 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
|
||||
* [Rule ID Namespace & Collision Guards](rules/rule-id-namespace.md) - Why rule identity is the human-readable ruleId (no GUIDs) with tenant isolation via Table Storage partitioning, the reserved built-in namespace (RuleIdPolicy), which collisions are possible (custom-vs-custom across tenants: allowed and harmless), and the guards keeping the global RuleStats aggregate catalog-only and the global partition inside the reserved namespace (combine.js, reseed, cross-tenant RuleState GC).
|
||||
* [Analyze Rule Evaluation Triggers](rules/analyze-rule-triggers.md) - Opt-in `evaluateOn` interim triggers (`whiteglove_sealed`, `on_event:<type>`) so rules can fire before a session is terminal, with RuleResults update-semantics (interim → finalize/resolve, NotifiedAt notification dedupe) replacing the permanent dedupe freeze, KO/stat suppression on interim runs, the ingest-side cached trigger registry, and the authoring lints that keep interim rules monotonic.
|
||||
|
||||
# Backend
|
||||
|
||||
* [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow.
|
||||
* [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events.
|
||||
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
|
||||
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
|
||||
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
|
||||
|
||||
# MCP Server
|
||||
|
||||
@@ -55,6 +76,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
|
||||
|
||||
# Conventions for this bundle
|
||||
|
||||
|
||||
+110
-1
@@ -1,6 +1,115 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-08
|
||||
## 2026-08-24
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — shadow telemetry moved off the dead `LogInformation` path: the `Match` outcome now rides on the request row as the `CertTenantBinding` dimension (`SecurityValidator` → `FunctionContext.Items` → `RequestTelemetryMiddleware`), which is unsampled and already exists per request, so the denominator costs nothing. Non-Match outcomes keep their Warning trace. Post-deploy verification of 249e39bc found zero rows across 32k requests — worker `LogInformation` never reaches App Insights (provider default rule Warning+), making "everything matched" indistinguishable from "never ran". `CertificateValidationResult.FromCache` removed with the sampling path it existed for.
|
||||
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
|
||||
|
||||
## 2026-08-23
|
||||
|
||||
* **New**: `backend/telemetry-ingest-shaping.md` — `RequestTelemetryMiddleware` sets `SamplingPercentage=100` on its own item (worker request rows were adaptively sampled, ItemCount 2–5 on weekdays, despite host.json `excludedTypes`), the redundant host HTTP request copy is dropped by a workspace transformation DCR (`infra/appinsights-workspace-transforms.bicep`, timer/queue rows kept), and `StorageDependencyFilterProcessor` now also drops expected storage outcomes (404/412/409, both InProc and HTTP shapes) and successful SignalR REST calls; the host-emitted SignalR binding calls (bulk of the volume, SDK `rdddsc`) are dropped by a second `AppDependencies` flow in the same DCR. Measured ≈ −1.3 GB/week.
|
||||
* **Update**: `backend/table-schema-sentinel.md` — "Measuring it": `StartupTelemetryService` emits `BackendStartupMs` (process start → ApplicationStarted) and `BackendTableInitMs` as App Insights custom metrics with version/commit/fullPass dimensions; the MCP server writes one `{"type":"boot","readyMs"}` stderr line per start (query in `.claude/commands/mcp-telemetry.md` §A2).
|
||||
* **New**: `backend/table-schema-sentinel.md` — startup table initialization gated by a schema sentinel (SHA-256 over `TableNames.All` in `AdminConfiguration`/`SchemaSentinel`/`tables`): matching sentinel ⇒ zero `CreateTableIfNotExists` calls on a cold start, missing/stale ⇒ one full pass + sentinel rewrite. SessionsIndex startup backfill only after a full pass, `IsSessionIndexEmptyAsync` no longer treats errors as "empty", conditional-insert claim so one scaled-out instance backfills. Daily maintenance runs `IStorageInitializer.EnsureAllAsync` to repair out-of-band table deletions.
|
||||
* **Update**: `backend/verdict-calibration.md` — MCP `get_verdict_calibration` response shaping: per-row trend denominators dropped, explicit nulls, `minSharePct` / `top` with an `omitted` report.
|
||||
* **Update**: `backend/verdict-calibration.md` — share-regression radar restricted to backend-decided paths; agent-declared/registration paths (customer workflow mix) and derived legacy paths no longer alert, stale episodes re-arm themselves.
|
||||
* **Update**: `backend/time-attribution.md`, `backend/device-journeys.md`, `backend/verdict-calibration.md` — the rolling maintenance sweeps (time attribution, device journeys, verdict calibration, calibration-radar discovery) now share ONE projected 35-day cross-tenant Sessions scan per tick (`LoadSweepWindowSessionsAsync` + `MaintenanceSweepSessionProjection`, pinned by `MaintenanceSweepProjectionEquivalenceTests`) instead of four full-table drains; each sweep slices its own window by StartedAt.
|
||||
* **New**: `backend/verdict-calibration.md` — the classifier thermometer: every status write now stamps `VerdictPath` (required parameter on `UpdateSessionStatusAsync`; `EnrollmentTimeoutClassifier` returns its rule id), overriding a verdict preserves `PriorStatus`/`PriorVerdictPath`, `VerdictPathDerivation` attributes pre-instrumentation rows read-side (`legacy:*` when ambiguous). New daily aggregate `VerdictCalibrationAggregates` (per-path count/derived/eligible7d/reEnrolled7d/overridden-by-admin|late|other, sweep after the device-journey pass, 180d retention), `GET global/metrics/verdict-calibration`, MCP `get_verdict_calibration`, admin metrics section, and the `VerdictCalibrationRadar` (share / silence-share / r6 evidence-gap episodes in the tracker keyspace `verdictcalibration|`, `VerdictCalibrationDrift` ops event, no tenant bell).
|
||||
* **New**: `backend/silent-session-classification.md` — one place for the silent-session verdict: `EnrollmentTimeoutClassifier` rule order incl. the new rule 1c `IsSelfDeployingProvisioned` (self-deploying profile + Device Setup all-succeeded + no explicit failure ⇒ Succeeded reconcile, no grace/AwaitingUser — kiosk tenant aebdce78 audit: 836 finished devices parked AwaitingUser/Incomplete), the sweep stage-1 gates (`TryMarkWhiteGloveAwaitingUserAsync`, new `TryReconcileSelfDeployingAsync`), the ingest callers (max-lifetime, late-telemetry heal, stall probe) and the new admin retro mode `self_deploying_silent` (`GetSelfDeployingSilentSessionsAsync`). Agent companion: `SessionRegistrationHelper` waits up to 15s for a network link and retries 6× (~62s) instead of 5× (~30s) so the reboot relaunch on Wi-Fi no longer exits 7 before the link is back.
|
||||
* **New**: `agent/diagnostics-package.md` — the diagnostics ZIP's built-in collection is now ONE catalog in Shared (`DiagnosticsBuiltInSections.All`): the agent iterates it in order and the backend serves the same list to the portal (`GET diagnostics/paths`, MemberRead), so what administrators see is exactly what the agent collects. New conditional sections: RealmJoin client/package/Chocolatey/per-user logs behind the tenant's RealmJoin Watcher toggle (mirrored onto `AgentConfiguration.EnableRealmJoinWatcher` by the merger) and the Device Preparation bootstrapper event log behind the deterministic WDP marker (probe once per build, fail toward Classic); every skip is a manifest line. Built-ins bypass `DiagnosticsPathGuards` by design (reviewed code, `%ProgramData%\RealmJoin` is not allowlisted); caps/reparse/evtx export still apply. `MergeDiagnosticsLogPaths` dedups global ∪ tenant paths. Portal: collapsed read-only catalog + one-line rows in both cards, tenant context dropped the GA-only global fetch.
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — `DiagnosticsPathGuards` scope clarified: configured diagnostics paths only, never the built-in catalog.
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Template overrides" section: Global Admins can replace the welcome/farewell HTML in the portal (Settings → Email Templates); `EmailTemplateService` resolves override-or-built-in per send (PreviewConfig table, partition `EmailTemplates`, 5-min cache, `{{domainName}}` placeholder, 30k limit, fail-soft send path), GA endpoints `global/email-templates/{kind}` (+`/test` to the caller's tenant contact address). Also: the mail header is image-free (CSS-drawn brand mark, gradient stripes and clip-path edge as progressive enhancement).
|
||||
|
||||
## 2026-08-22
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it.
|
||||
|
||||
* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace.
|
||||
|
||||
## 2026-08-21
|
||||
|
||||
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
|
||||
|
||||
* **New**: `agent/system-timeline-watcher.md` — clock-step and sleep-episode ground truth from the System event log: one `EventLogWatcher` (Kernel-General 1, Power-Troubleshooter 1, Kernel-Power 507; 506 deliberately unconsumed) emits `system_clock_changed` (oldTime/newTime/signed timeDeltaMs/reason/process; 2 s micro-slew floor, ≥5 min = Warning+immediate) and `system_sleep_episode` (kind sleep/hibernate/modern_standby, authoritative `enteredAt`/`exitedAt` payload instants, 60 s Modern-Standby floor). WindowsUpdateTracker-style backfill (24 h default, `system-timeline-watermark.json`) recovers pre-agent records; ConfigVersion 39 (`Collectors.EnableSystemTimelineWatcher`). Consumers: TimeJumpBadge cause `clock-set` (ground truth beats inference), SessionInfoCard standby note next to the unchanged wall-clock duration, `SleepSpan`s in time attribution (AttributionVersion 3, RebootSpan clone), and ANALYZE-DEV-012 (info, single, ≥5 min episode; deliberately the only new rule — clock issues stay with DEV-008).
|
||||
|
||||
* **New**: `agent/power-state-watcher.md` — live AC/battery observation during enrollment: `PowerStateWatcherHost` subscribes to the WMI push `Win32_PowerManagementEvent EventCode 10` (no polling; re-probes `GetSystemPowerStatus` per push, 5 s trailing-edge debounce, never armed on battery-less devices, `Collectors.EnablePowerStateWatcher` kill-switch, ConfigVersion 38). The pure `PowerStateTransitionTracker` emits `power_state_change` with `transition` = ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (each level latched once per lifetime, lowest-crossed-only on jumps, baseline-below-level covered, emission cap 20). Classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent). New analyze rules ANALYZE-DEV-009 (thresholdPercent=15, high) and ANALYZE-DEV-010 (transition=ac_to_battery, warning), both with `on_event:power_state_change` interim triggers and deliberately single-required-condition (event_data conditions have no same-instance join).
|
||||
|
||||
* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields.
|
||||
|
||||
* **New**: `rules/rule-id-namespace.md` — rule identity model: human-readable `ruleId` strings (no GUIDs), tenant isolation via Table Storage partitioning (`global` = built-in/community, tenantId = custom), the reserved built-in namespace from `RuleIdPolicy`, and the four collision guards: catalog-only global `RuleStats` aggregate (same-ID custom rules of unrelated tenants no longer sum into one `global_{ruleId}` row), combine.js duplicate/namespace fail, GitHub-reseed rejection of non-reserved IDs into the global partition, and the namespace gate on the cross-tenant RuleState GC.
|
||||
|
||||
* **New**: `backend/app-install-duration-attempt.md` — attempt-scoped app install durations: `DurationSeconds` = status-defining (last) attempt anchored on the new max-folded `LastAttemptStartedAt` (StartedAt stays the earliest-wins window/version-ordering key; legacy rows without the anchor keep the span fallback); `InstallPassCount` with replay-guarded additive fold; weaker-terminal guard (completed-with-Skipped never overrides Installed/Error, in-batch and cross-batch); attempt-scoped `DownloadDurationSeconds` pairing; the version-regression radar's both-sides cutover gate; time attribution counts the union of per-pass active segments (occupancy merges segments, hulls stay for display/what-if); agent payload timing re-arms both endpoints on terminal→active. No backfill — pre-cutover rows keep span semantics.
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — activity-based RealmJoin timeout extension (report 55e6afd61c9d: a large RJ package catalog outlived the 60-min window mid-install — the timer arms at RJ-agent-MSI detection, so the first deployment had effectively ~44 min). When the `RealmJoinTimeout` deadline fires with `LastDeploymentPhase` 100/101 and deployment activity (phase change or package observation, persisted in `RealmJoinFacts.LastActivityUtc`; the detection-time phase capture deliberately excluded) inside the last 60 min, the deadline re-arms to `lastActivity + 60 min` (Info event `realmjoin_timeout_extended`), clamped at detection + 4 h. `realmjoin_timeout` now carries `reason` = `hard_timeout` / `inactivity` / `absolute_cap`; a fire of a superseded deadline incarnation dead-ends. Idle detection and phase-200/210 sessions keep the original 60-min behavior.
|
||||
|
||||
* **New**: `backend/send-time-frame-separation.md` — P14: the agent stamps one device-clock send timestamp per upload attempt (`X-Send-Time-Utc`), the ingest function server-stamps it as `SentAt` on every event of the request. `SentAt − OccurredUtc` is pure spool delay, `ReceivedAt − SentAt` is network + device-vs-server clock offset — the two were indistinguishable before and had to be separated statistically. The `clock_skew` condition measures SentAt-carrying batches directly (spool-immune, no spread cap, IME-only sessions become measurable; evidence carries `sentAtBatchCount`), legacy batches keep the median path; the CMTrace tripwire deliberately stays on its differential math (common-mode clock offset cancels there).
|
||||
|
||||
## 2026-08-20
|
||||
|
||||
* **Update**: `agent/cmtrace-time-resolution.md` — new "Backend regression tripwire" section: the P6 split. Operator side: `CmTraceSkewTripwire` piggybacks on the terminal counter reconcile's partition scan (`Δ = ReceivedAt − OccurredUtc` per source, 15-min grid + 2-min residual, ≥20 samples / ≥3 distinct batches per side, bias-dominated sessions suppressed, `measuredWriterOffsetMinutes` never consulted) and emits the `CmTraceTimeSkewRegression` ops event — goal state: never fires; kill switch `CmTraceSkewTripwireDisabled`. Customer side: new `clock_skew` analyze condition source (`skewMetric: clock_jump | sustained_offset`, threshold in seconds, batch-median frames with spool-spread filter, plateau step detection, end-state persistence, IME-derived events excluded) behind the new built-in `ANALYZE-DEV-008` (300 s = the Kerberos/Entra 5-minute skew boundary) — the during-enrollment complement of ANALYZE-DEV-007's one-shot NTP check. P5 (agent-side observability event) is subsumed by the tripwire.
|
||||
|
||||
* **New**: `agent/cmtrace-time-resolution.md` — the era-aware answer to the CMTrace timezone skew: a bias-less line read in a provably fresh pass anchors its own UTC offset against the agent clock (15-minute grid, 2-minute residual), so interleaved writer eras in one file resolve line by line; everything not provably fresh keeps the uniform reader-zone fallback and says so via `sourceOffsetOrigin`. Freshness is a per-file `LastCheckedUtc` stamped on every poll look (empty first sights included) with a 30-second window, never restored across restarts. Replaces the reverted per-file offset application (04b1a7c6); the per-file measurement stays observational, now with the anchor timestamp logged as a tripwire for the still-unexplained e9753578 label/anchor crossing.
|
||||
|
||||
* **Update**: `agent/post-reboot-completion-recovery.md` — retitled to "ESP Completion Starvation — the sits-d Cloud-PC complex" and reframed on the evidence: the reboot was a red herring. The five stuck Cloud PCs carried `SkipUserStatusPage=True` (read 10+ times across three agent processes, `True` every time) and Windows honoured it — no user ESP page, hence no Shell-Core 62407 and no AccountSetup provisioning registry, verified over a six-hour uninterrupted post-reboot run with the watcher armed. Arm B of `ShouldTransitionToAwaitingHello` was open from the bootstrap onward, but every evaluation site hangs off a signal a skip-user flow cannot produce, and the `AdvisoryCompletion` backstop arms exclusively from an `EspExiting` signal — an open gate nobody knocks on. Fix: `HandleImeUserSessionCompletedV1` accepts the observed skip in place of the final-exit evidence (`skipUserExitEquivalent`; anchor + genuine IME completion + real-user desktop stay mandatory, WDP excluded). The knock deliberately lives on the IME edge, not desktop arrival — ordering-robust against Fix 10's AccountSetup bounce-back and semantically "user-phase work finished" rather than mid-install. `ClassicSkipUserEspUserSessionCompletionTests` replays the real 8110e262 ordering end to end. Docs and comments no longer claim an ESP-exit recovery that does not exist (stray duplicate summary in `ShellCoreTracker`, `ImeLogHost` restore-callback comment); the residual `SkipUser=false` exit-in-downtime gap is named honestly and surfaced by `ANALYZE-ESP-005` via `session_stalled` instead of being patched with an unorderable replay.
|
||||
|
||||
## 2026-08-19
|
||||
|
||||
* **New**: `agent/post-reboot-completion-recovery.md` — a forced mid-ESP reboot (DeviceGuard/DmaGuard) used to cost the session its AccountSetup completion permanently. Two defects: `ShellCoreTracker.BackfillRecentEspExitEvents()` existed but had no caller, so every agent restart dropped an ESP exit (62407) written while it was down; and the user-apps-settled synthesis was edge-triggered on `EspExited` only, so apps settling afterwards never re-opened the gate. `EspAndHelloHost.Start()` now backfills with a lookback sized to the downtime (`previousExit=reboot_kill` → since `LastBootUtc`, clamped to 360 min), and `ReevaluateUserAppsSettledSynthesis()` is chained into `ImeLogTracker.OnAppStateChanged`. No new completion path — the gate conditions are unchanged. Evidence: sits-d Cloud PCs, five `Incomplete` sessions on healthy devices, two of them at `138/138 apps, 0 failed`. Hardened over two review rounds: the remembered edge must be a **live** exit that is **confirmed** post-AccountSetup (a replay carries no evidence of its own position — Shell-Core writes the same `CommercialOOBE_ESPProgress_Page_Exiting` for the intermediate and the final exit, and the registry check reads *now*, not the event's time); the replay hands over the **newest** exit rather than the oldest; the replay is narrowed to the Hello-wizard start (62404) alone — a conservative fact that can only make the agent wait longer. A replayed ESP exit is never re-raised at all: Windows writes the identical description for the intermediate and the final exit, the reducer orders exits by ingest ordinal (fresher than reality for a replay) and `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit, so arm C of `ShouldTransitionToAwaitingHello` would open on a historic handoff once the restored state carries AccountSetupEntered + IME user session + desktop. A replayed ESP failure is likewise dropped (it could fail a session that recovered). Skipped records are reported as an `agent_trace`. The window is **measured** from the previous run's last known liveness (snapshot mtime, widened by `LastBootUtc`) instead of a constant — the scheduled task has a BootTrigger only and no restart-on-failure, so a crashed agent returns hours later and five minutes would lose the exit every time. On `first_run` the replay stays off, leaving the happy path untouched.
|
||||
|
||||
* **Update**: `rules/analyze-rule-triggers.md` — `on_event:session_stalled` documented as an established interim trigger for the never-terminal stuck session (one emission per session, no throttle needed). `ANALYZE-ESP-005` carries it: both its conditions are monotonic and it has no terminal precondition, and it fires at confidence 95 on the sits-d stalled stream. `ANALYZE-APP-015` deliberately does NOT — it is the worked example of the terminal-precondition trap (lint 1) — and `ANALYZE-ESP-001` does not either, because its `phase_duration` gate on a still-open phase is unverified mid-run. New `ANALYZE-APP-016` ("Required Apps Never Started — ESP Apps Gate Starved") turns the existing `app_install_starved` events into a customer-facing finding and, unlike `ANALYZE-APP-015`, fires even when the session succeeded — session `3b9291aa` was recorded `Succeeded` with 99 required apps that never started installing.
|
||||
|
||||
* **New**: `script-publishing.md` — the customer script publishing chain. `.github/scripts/Publish-BootstrapScripts.ps1` becomes the single owner (dev render, version-bump guard, uploads, version oracles, alias verification), called both by the new `publish-scripts.yml` (push to `main` on `scripts/Bootstrap/**` and `scripts/CustomerSetup/**`) and by the `publish_as_stable` branch of `build-agent.yml`. Script publishing no longer waits for an agent release. Blobs are published in a deterministic CRLF/UTF-8-no-BOM form so byte comparisons do not depend on the checkout; the Pester + ASCII gates move into the reusable `bootstrap-script-gates.yml` and block the upload; `AdminConfiguration.LatestBootstrapV2ScriptVersion` now has exactly one writer. The version-bump guard compares a code fingerprint (PowerShell token stream without comments, newlines and line continuations), so only behaviour changes require a `$ScriptVersion` bump — comment and formatting changes publish with a warning.
|
||||
|
||||
## 2026-08-18
|
||||
|
||||
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
|
||||
|
||||
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
|
||||
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
|
||||
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
|
||||
|
||||
## 2026-08-17
|
||||
|
||||
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
|
||||
|
||||
## 2026-08-14 (later)
|
||||
|
||||
* **Update**: `rules/analyze-rule-triggers.md` — status SPEC → IMPLEMENTED. Shipped: `evaluateOn` on the analyze-rule schema (grammar-validated server-side and in validate_rule), `AnalyzeRunContext`-driven engine runs (Terminal/Interim/Reanalyze) with the RuleResult lifecycle columns (FirstDetectedAt/LastEvaluatedAt/IsInterim/ResolvedAt/NotifiedAt), interim rule filtering + KO/stat suppression, resolve/finalize update-semantics, the NotifiedAt one-send-per-(session,rule) notification dedupe (preserved across the manual reanalyze rebuild), the ingest-side `InterimTriggerRegistry` (5-min TTL, fail-soft) gating `interim_trigger` / `whiteglove_sealed` enqueues, the three MCP authoring lints, web preliminary/resolved rendering, and ANALYZE-ID-004 as the first interim-enabled rule.
|
||||
|
||||
## 2026-08-14
|
||||
|
||||
* **Creation**: Added `rules/analyze-rule-triggers.md` — SPEC for opt-in `evaluateOn` interim evaluation of analyze rules: `enrollment_end` default preserves today's behavior, `whiteglove_sealed` + `on_event:<type>` close the WhiteGlove notification gap and the never-terminal stuck-session gap; RuleResults update-semantics (FirstDetectedAt/IsInterim/ResolvedAt/NotifiedAt) replace the permanent dedupe freeze; interim runs suppress KO + stats, notifications fire on first detection; three new validate_rule lints (terminal-precondition trap, unknown trigger type, KO-on-interim).
|
||||
|
||||
## 2026-08-13 (late evening)
|
||||
|
||||
* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet.
|
||||
|
||||
* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published.
|
||||
|
||||
## 2026-08-13 (evening)
|
||||
|
||||
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
|
||||
|
||||
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
|
||||
|
||||
## 2026-08-13
|
||||
|
||||
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
|
||||
|
||||
## 2026-08-12
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
|
||||
|
||||
## 2026-08-11 (later)
|
||||
|
||||
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
|
||||
|
||||
## 2026-08-10
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
|
||||
|
||||
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ Three layers answer "are the MCP tools doing their job, and are tools missing?":
|
||||
Logs) — the layer that carries what the other two structurally cannot:
|
||||
- `tool_call`: duration, `isError` **including soft errors** (handlers never
|
||||
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
|
||||
records those calls as successes), `resultChars` + `overCap` (result
|
||||
records those calls as successes), `errorMessage` (capped; the thrown
|
||||
message or the first lines of the `toolError` text, which name the error
|
||||
class — HTTP status / timeout / auth / not found; absent on success),
|
||||
`resultChars` + `overCap` (result
|
||||
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
|
||||
it; a frequently-overCap tool needs tighter defaults or projections), a
|
||||
size-bounded args summary, and the caller scope (ga/delegated/tenant).
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Analyze Rule Evaluation Triggers (evaluateOn)
|
||||
description: "Opt-in interim evaluation of analyze rules before a session is terminal — enrollment_end stays the default, whiteglove_sealed and on_event:<type> triggers close the WhiteGlove notification gap and the never-terminal stuck-session gap with one mechanism, backed by update-semantics on RuleResults instead of the permanent dedupe freeze."
|
||||
resource: backend/rule-engine
|
||||
tags: [rules, analyze, triggers, notifications, whiteglove]
|
||||
timestamp: 2026-08-14
|
||||
---
|
||||
|
||||
# Status
|
||||
|
||||
**IMPLEMENTED 2026-08-14** (same day the spec was approved). The open product decisions below were confirmed as "no" for v1. Key implementation anchors: `AnalyzeRuleTriggers` (Shared — grammar + matching helpers, shared by engine, ingest registry and CRUD validation), `AnalyzeRunContext` / `AnalysisOutcome.ResolvedResults` (RuleEngine), `InterimTriggerRegistry` (5-min-TTL per-tenant cache for the ingest hot path, fail-soft), `AnalyzeOnEnrollmentEndHandler` interim branch, `EvaluateOnJson` + five RuleResult lifecycle columns in `TableStorageService.Rules`. ANALYZE-ID-004 ships as the first interim-enabled rule (`on_event:hybrid_login_pending`, repetition-gated at threshold 75).
|
||||
|
||||
# Problem
|
||||
|
||||
Analyze rules run at exactly three points today, all of them terminal or near-terminal:
|
||||
|
||||
1. `enrollment_complete` / `enrollment_failed` observed at ingest (`EventIngestProcessor.cs`, classification → analyze envelope).
|
||||
2. The maintenance sweep terminalizing a silent session (Failed / Incomplete + synthetic `session_timeout`, `MaintenanceService.cs`).
|
||||
3. The vulnerability-correlation rerun (`vulnerability_correlated` reason — incremental, no stats).
|
||||
|
||||
Two incident classes fall through this model:
|
||||
|
||||
- **WhiteGlove gap (sits-d, 2026-08-11):** `whiteglove_complete` seals a session to Pending but enqueues no analyze run. A rule that should alert at the end of pre-provisioning (technician still at the bench) never fires, and when Part 2 eventually terminalizes days later, the permanent per-(session, rule) dedupe means a finding that sneaked in earlier (e.g. via the vuln-rerun backdoor) is frozen and never notifies.
|
||||
- **Never-terminal stuck sessions (esa-logistics, 2026-08-13/14):** a session looping in Account Setup (user signs in daily, hybrid affinity never establishes) resets the 5h inactivity clock on every logon. It is never terminalized, therefore never analyzed — the exact sessions where a diagnosis would help most (ANALYZE-ID-004, ANALYZE-APP-015) produce no finding while the customer watches the device loop.
|
||||
|
||||
Both gaps share one root cause: *evaluation time is hard-wired to session end*. Gather rules already solved the analogous problem on-device with declarative triggers (`startup` / `phase_change` / `phase_exit` / `interval` / `on_event`); analyze rules need the server-side equivalent.
|
||||
|
||||
# Design Overview
|
||||
|
||||
Add an optional `evaluateOn` array to the analyze-rule schema. Absent field ⇒ `["enrollment_end"]` ⇒ **today's behavior, bit-for-bit, for every existing rule**.
|
||||
|
||||
```
|
||||
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"]
|
||||
```
|
||||
|
||||
Trigger grammar (v1):
|
||||
|
||||
| Trigger | Fires when | Notes |
|
||||
|---|---|---|
|
||||
| `enrollment_end` | terminal analyze run (complete / failed / sweep-terminalized) | default; also the **finalization pass** for interim results (below) |
|
||||
| `whiteglove_sealed` | ingest classifies the first genuine `whiteglove_complete` (session → Pending, `isPreProvisioned`) | duplicate seals do not re-trigger |
|
||||
| `on_event:<eventType>` | an ingest batch contains ≥1 event of `<eventType>` | `<eventType>` validated against the event-types catalog; phase boundaries are covered by `on_event:phase_transition` / `on_event:esp_phase_changed` — no separate phase grammar needed, since every run evaluates the full stream anyway |
|
||||
|
||||
Explicitly **not** in v1: interval/timer triggers (no cheap host for them; the sweep remains the only clock), and `on_event` filters on data fields (the rule's own conditions do that — the trigger only decides *when* to look).
|
||||
|
||||
# Pipeline Changes
|
||||
|
||||
## Ingest (producer side)
|
||||
|
||||
- `EventIngestProcessor` already classifies each stored batch. After classification, intersect the batch's event types with the tenant's **trigger registry** (the set of `on_event` types across the tenant's active rules — derivable from `AnalyzeRuleService`'s merged rule load, cached alongside it). Non-empty intersection ⇒ enqueue one envelope `Reason=interim_trigger`, carrying the matched trigger event types. Reuse the existing 30s visibility delay (terminal-flush ordering argument in `AzureQueueAnalyzeOnEnrollmentEndProducer` applies to interim batches too).
|
||||
- The `whiteglove_complete` seal path (`EventIngestProcessor.Classification.cs`) enqueues `Reason=whiteglove_sealed` — only on the first seal, only for `isPreProvisioned`.
|
||||
- Terminal enqueue (`CompletionEvent` / `FailureEvent`) is unchanged.
|
||||
|
||||
## Handler / engine (consumer side)
|
||||
|
||||
`AnalyzeOnEnrollmentEndHandler` branches by reason today (primary vs. `vulnerability_correlated`); interim reasons become a third branch. The engine call gets a run context instead of the bare `reanalyze` flag:
|
||||
|
||||
| Aspect | Terminal run (`enrollment_end`) | Interim run (`whiteglove_sealed`, `interim_trigger`) |
|
||||
|---|---|---|
|
||||
| Rule set | ALL active rules (interim-triggered ones get their finalization pass here) | only rules whose `evaluateOn` matches the reason / one of the batch's trigger types |
|
||||
| KO (`MarkSessionAsFailed`) | applies (unchanged) | **suppressed** — a Pending/InProgress session must never be flipped Failed by an interim pass (`TryMarkSessionFailedFromRuleAsync` is skipped; the terminal run re-fires the rule and applies KO then) |
|
||||
| Platform stat `IssuesDetected` | counted | skipped (like vuln rerun) |
|
||||
| Per-rule fire stats | counted | skipped (like vuln rerun) — keeps the terminal-only stats convention and the Rule Regression Radar baselines clean |
|
||||
| Channel notifications | new findings notify (unchanged) | new findings notify — **this is the point** of both gaps |
|
||||
| SignalR results-available | yes | yes (live update of an open session page) |
|
||||
|
||||
## Result lifecycle: update semantics replace the dedupe freeze
|
||||
|
||||
Today the engine skips any rule that already has a stored `RuleResult` row for the session (`RuleEngine.AnalyzeSessionAsync`, existing-results lookup) — findings are frozen at first write, which is precisely the WG-gap failure mode ("dedupe permanent").
|
||||
|
||||
New model — `RuleResult` gains four fields (`FirstDetectedAt`, `LastEvaluatedAt`, `IsInterim`, `ResolvedAt`, plus `NotifiedAt` for the notification marker):
|
||||
|
||||
- **Interim run:** rules in scope are (re-)evaluated even when a row exists. Fired ⇒ upsert (evidence/confidence refreshed, `FirstDetectedAt` preserved, `IsInterim=true`). Not fired + existing interim row ⇒ `ResolvedAt` set (kept for audit; excluded from issue counts and default UI).
|
||||
- **Terminal run:** every rule that has an interim row is re-evaluated regardless of dedupe (finalization). Fired ⇒ row finalized (`IsInterim=false`, evidence refreshed — fixes the stale-template-field freeze, e.g. the ESP-004 `{{appName}}` case). Not fired (typically a `not_exists` precondition on `enrollment_complete` now failing because the session healed) ⇒ `ResolvedAt` set. Rules without interim rows keep today's skip-if-exists dedupe.
|
||||
- **Notification dedupe** moves off "row exists" onto `NotifiedAt` on the row: notify once per (session, rule) at first fire — interim or terminal, whichever comes first; refreshes and finalization do not re-notify. The manual `reanalyze=true` path must **preserve** `NotifiedAt` across its rebuild (today it deletes rows; delete-and-recreate would silently re-arm notifications) and keeps its designed never-notify behavior.
|
||||
- Legacy rows (null new fields) read as final/terminal — no migration.
|
||||
|
||||
# Authoring Guardrails (validate_rule lints)
|
||||
|
||||
Interim evaluation changes what a rule's conditions mean mid-flight. Three new lints in the MCP `validate_rule` pre-flight (and the baked authoring guide):
|
||||
|
||||
1. **Terminal-precondition trap (warning):** `evaluateOn` contains an interim trigger AND a precondition is `not_exists` on `enrollment_complete` / `enrollment_failed` / `session_timeout`. Mid-run these pass trivially — the rule needs conditions that are *monotonic* (once true on the growing event stream, stays true) or repetition-gated. Worked example: ANALYZE-APP-015 must NOT get an interim trigger as-is — even successful sessions transiently show its "completion signal deferred" + `pending>=1` pattern (the terminal precondition is its only gate). ANALYZE-ID-004 is interim-safe by construction (threshold 65 requires repeated `hybrid_login_pending`).
|
||||
2. **Unknown trigger event type (error):** `on_event:<type>` not in the event-types catalog.
|
||||
3. **KO-on-interim (warning):** `markSessionAsFailedDefault: true` on a rule whose only triggers are interim — the KO would never apply until terminal; say so.
|
||||
|
||||
# Cost & Safety
|
||||
|
||||
- Interim runs are bounded by trigger frequency, and the chosen v1 triggers are rare by construction (`hybrid_login_pending` ≤1 per boot, `whiteglove_sealed` once per session). High-frequency telemetry event types (performance_snapshot, agent_metrics_snapshot, download_progress, network churn, log_entry, agent_trace, stall_probe_check) are **hard-blocked** as `on_event` triggers, not merely advised against: the CRUD write is rejected (`AnalyzeRuleTriggers.BlockedOnEventTypes`, code-only enforcement), the runtime matcher drops them from any row that slipped in (defense in depth — the trigger registry and the engine both go through `OnEventTypes`), and the MCP lint reports them as an error. The JSON mirror lives in `rules/guardrails.json` (`blockedInterimTriggerEventTypes`) with a parity test pinning it to the C# set — the same pattern the gather hard blocks use. If a legitimate rare-but-bursty type ever hurts, a per-(session, reason) throttle marker is the v2 lever.
|
||||
- Each run loads the full event stream once (existing engine behavior, no change); rule filtering makes interim passes strictly cheaper than terminal ones.
|
||||
- Queue semantics (retry via visibility timeout, poison after MaxDequeueCount, idempotent partial-success re-runs) are inherited unchanged; upserts keep idempotency.
|
||||
|
||||
# Surfaces
|
||||
|
||||
- **Web session page:** interim findings render live (existing SignalR results-available notification) with a "preliminary" badge on `IsInterim`; `ResolvedAt` rows hidden by default behind a toggle.
|
||||
- **MCP:** `get_session(includeAnalysis)` / `get_session_summary` expose `isInterim` / `resolvedAt`; `list_session_annotations`' fired-rule snapshot semantics unchanged (labels bind to whatever was fired at annotation time).
|
||||
- **Success-rate / platform stats:** terminal-only convention untouched (interim runs record no stats).
|
||||
|
||||
# Application to the Motivating Cases
|
||||
|
||||
- WG battery rule (sits-d): shipped 2026-08-21 as ANALYZE-DEV-009 (battery below 15% while on battery) and ANALYZE-DEV-010 (AC→battery switch), both `evaluateOn: ["enrollment_end", "whiteglove_sealed", "on_event:power_state_change"]` on the live power watcher's `power_state_change` events (see `../agent/power-state-watcher.md`) — technician gets the channel alert at the bench; the terminal pass finalizes the finding. Both are deliberately single-required-condition rules: `event_data` conditions scan events independently (no same-instance join), so DEV-009 matches the agent-stamped `thresholdPercent` and DEV-010 matches `transition`.
|
||||
- Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1).
|
||||
|
||||
- Stuck after a forced mid-ESP reboot (sits-d Cloud PCs, 2026-08-19): `on_event:session_stalled` is the established trigger for the never-terminal stuck session. The agent emits `session_stalled` exactly once per session (60-minute stall probe), so the trigger is rare by construction and needs no throttle. Five Cloud PCs sat at `InProgress` with **zero findings** for hours — a mid-ESP reboot had killed the agent in `Stage=EspAccountSetup` — before the max-lifetime watchdog eventually terminalised them as `Incomplete`. ANALYZE-ESP-005 carries the trigger and fires on that stream at confidence 95 (verified against session `8110e262`): both its conditions (`mdm_policy_reboot_required.firstRebootUri` exists, `system_reboot_detected` count ≥ 1) are **monotonic** and it has no terminal preconditions, so lint 1 does not apply. ANALYZE-APP-016 is interim-safe for the same reason (`app_install_starved` count ≥ 1 only ever grows).
|
||||
|
||||
Two rules were considered and deliberately **not** given the trigger: ANALYZE-APP-015 is the worked example of lint 1 above and stays `enrollment_end`-only; ANALYZE-ESP-001 gates on `phase_duration` of a phase that is still open mid-run, and that evaluation is not pinned by a test — an unverified trigger is not worth a possible false positive on every slow DeviceSetup.
|
||||
|
||||
# Product Decisions (settled 2026-08-14)
|
||||
|
||||
1. Notify-on-resolve (a "finding healed" message)? **No** (v1) — resolution is visible in UI/MCP.
|
||||
2. Re-notify on severity/confidence escalation of an existing finding? **No** (v1) — one notification per finding.
|
||||
3. `whiteglove_sealed` runs allowing KO rules to flip Pending → Failed? **No** — the Pending state is the WhiteGlove contract; suppression is unconditional for interim.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` — terminal-only enqueue (classification → analyze envelope).
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs` — reason branching, notify path, stats side-effects.
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs` — 30s visibility delay rationale.
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs` — existing-results dedupe, `TryMarkSessionFailedFromRuleAsync` KO path.
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — sweep terminalization + analyze enqueue; inactivity-clock reset explains the never-terminal gap.
|
||||
- `rules/gather-rule-phase-scoping.md` (this bundle) — the on-device trigger precedent this design mirrors.
|
||||
- `rules/rule-authoring-surface.md` (this bundle) — where the new lints land (validate_rule + baked guide).
|
||||
@@ -7,14 +7,14 @@ description: >-
|
||||
guard rather than trusting rule.Target.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||
tags: [gather-rules, security, guardrails, agent]
|
||||
timestamp: 2026-07-22
|
||||
timestamp: 2026-08-11
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -27,10 +27,16 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
|
||||
| --- | --- | --- |
|
||||
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
||||
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | `SELECT <* \| property-list> FROM <class>`; class = case-insensitive equality against classes derived from the `SELECT * FROM <class>` entries, boundary whitespace or end (trailing `WHERE` allowed). Non-canonical entries (none today) fall back to literal prefix + boundary |
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
`DiagnosticsPathGuards.IsDiagnosticsPathAllowed` (allowlist `diagnosticsPathPrefixes`, a C#
|
||||
literal mirrored in the JSON) applies the same hard blocks and the same user-profile
|
||||
exception to **admin-configured diagnostics paths** only. The diagnostics package's built-in
|
||||
sections are reviewed code and never pass through it — see
|
||||
[Diagnostics Package](../agent/diagnostics-package.md).
|
||||
|
||||
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
|
||||
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
|
||||
but the hard blocks must hold regardless of configuration:
|
||||
@@ -44,7 +50,12 @@ but the hard blocks must hold regardless of configuration:
|
||||
PowerShell channels (script-block logging routinely contains secrets in clear text).
|
||||
|
||||
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
|
||||
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
|
||||
`blockedEventLogChannels`, `blockedCommandPatterns`, `maxCommandLength`) **for display
|
||||
and pre-flight only**. Enforcement is the C# constant; `GuardrailsParityTests` (agent
|
||||
test project) pins the JSON mirror to the constants so neither side can drift. The
|
||||
portal (`utils/guardValidation.ts`) and the MCP validator (`rule-validation.ts`) apply
|
||||
the mirrored hard blocks in the same order as the agent — before the unrestricted-mode
|
||||
early-return — so a rule the agent would refuse is never shown as "Allowed".
|
||||
|
||||
# Examples
|
||||
|
||||
@@ -71,6 +82,15 @@ Two collectors were added to this contract on 2026-07-22 after shipping without
|
||||
remote config. The operator using it is already a local admin, so it relaxes the
|
||||
allowlist exactly the way unrestricted mode does — hard blocks still apply.
|
||||
|
||||
**WMI property projections (2026-08):** `SELECT BatteryStatus FROM Win32_Battery` is
|
||||
allowed because `SELECT * FROM Win32_Battery` is — a projection exposes a strict subset
|
||||
of the allowed class. The matcher parses the query shape instead of literal-prefix
|
||||
matching; the allowed-class set is derived at load time from the existing
|
||||
`wmiQueryPrefixes` entries, so `rules/guardrails.json` needs no new key. The same
|
||||
algorithm is mirrored in the portal (`utils/guardValidation.ts`) and the MCP server
|
||||
(`rule-validation.ts`); agents older than the change still block projections with a
|
||||
`security_warning` until they self-update.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
type: concept
|
||||
title: Rule ID Namespace & Collision Guards
|
||||
description: How gather/analyze rule identity is scoped (tenant partition vs global partition), why there are no GUIDs, which collisions are possible, and the guards that keep the reserved built-in namespace and the global RuleStats aggregate clean.
|
||||
resource: backend, rules
|
||||
tags: [rules, ruleid, namespace, collision, rulestats, tenant-isolation]
|
||||
timestamp: 2026-08-21
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Rule identity is the human-readable `ruleId` string — there are no GUIDs, deliberately: the ID appears in logs, mails, notifications, RuleResults, the agent config payload, and docs, and tenant isolation is provided by Table Storage partitioning instead.
|
||||
|
||||
| Store | PartitionKey | RowKey | Content |
|
||||
|---|---|---|---|
|
||||
| `GatherRules` / `AnalyzeRules` | `"global"` | ruleId | Built-in + community rules (shared, all tenants) |
|
||||
| `GatherRules` / `AnalyzeRules` | tenantId | ruleId | Custom rules (visible only to that tenant) |
|
||||
| `RuleStates` | tenantId | ruleId | Per-tenant enable/notify overrides — built-in/community rules ONLY (custom rules fold state into their own row) |
|
||||
| `RuleStats` | date | `{tenantId}_{ruleId}` and `global_{ruleId}` | Daily fire/eval telemetry; the global row is catalog-only (see guards) |
|
||||
|
||||
Namespace split (`RuleIdPolicy.cs`, shared model):
|
||||
|
||||
- **Reserved built-in namespace**: `(ANALYZE|GATHER)-<CATEGORY>-<NUMBER>` with `CATEGORY != CUSTOM`, case-insensitive, including currently unused numbers (gaps = retired rules that may return).
|
||||
- **Tenant namespace**: everything else — `ANALYZE-CUSTOM-NNN` (portal suggestion), `-CUSTOM` suffixes, org prefixes (`CONTOSO-WIFI-001`), freeform.
|
||||
|
||||
# Collision model
|
||||
|
||||
- **Custom vs built-in**: impossible via API — `CreateRuleAsync`/`UpdateRuleAsync` reject reserved IDs (409) and check existence against `global` ∪ own tenant partition. Legacy debris is neutralized at merge time: global wins, tenant copy dropped with a warning (pinned by `RuleMergeCollisionGuardTests`, motivated by the 5ca2b350 duplicate-key incident).
|
||||
- **Custom vs custom across tenants**: allowed and harmless — separate partitions, separate evaluation, separate RuleResults/tenant stats. Two tenants both using `ANALYZE-CUSTOM-101` never interact.
|
||||
- **JSON copy between tenants** (export → paste into create editor): goes through the same create path, so an ID already taken in the receiving tenant surfaces as 409 — the user renames. There is no bulk import endpoint. The JSON editors accept both shapes — a rule export and the serialized form — via `gatherRuleToForm` / `jsonToForm` (web `types.ts` of each rules page): nested gather `parameters` are unflattened, analyze `evaluateOn` maps onto the eval checkboxes, and `tags` plus the effective `markSessionAsFailed` survive the import.
|
||||
|
||||
# Guards (all enforced, 2026-08-21)
|
||||
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Analyze gates on `rule.IsBuiltIn || rule.IsCommunity` (`AnalyzeOnEnrollmentEndHandler`), gather gates on `RuleIdPolicy.IsReservedBuiltInId` (`EventIngestProcessor.RuleStats`) because agent events carry no flag — valid because custom rules can never occupy the reserved pattern.
|
||||
2. **Catalog build fails on namespace violations.** `rules/scripts/combine.js` rejects duplicate IDs (case-insensitive) and any gather/analyze built-in outside the reserved pattern; `BuiltInRuleCatalogPolicyTests` pins the same invariants on the embedded resource actually deployed.
|
||||
3. **GitHub reseed rejects non-reserved IDs** before writing to the `global` partition (`ReseedFromGitHubFunction`) — a seeded `*-CUSTOM-*` rule would silently shadow every tenant's same-ID custom rule at merge time.
|
||||
4. **Cross-tenant RuleState GC is namespace-gated.** `DeleteRuleStatesForRuleIdAcrossTenantsAsync` deletes by bare RowKey across all partitions; it now refuses non-reserved IDs with `failed=-1` (callers then skip the global catalog delete, same contract as enumeration failure).
|
||||
|
||||
Invariant chain: schema pattern + combine.js + reseed guard ⇒ the `global` partition only ever contains reserved-pattern IDs ⇒ the pattern is a safe built-in/custom discriminator everywhere no `IsBuiltIn` flag is available.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleIdPolicy.cs` — reserved pattern + rationale
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs`, `GatherRuleService.cs` — create/update guards, merge collision guard
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs` — key schemes, cross-tenant GC guard
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRuleCatalogPolicyTests.cs`, `RuleIdReservationTests.cs`, `RuleMergeCollisionGuardTests.cs`
|
||||
- `rules/scripts/combine.js` — build-time namespace/duplicate guard
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Customer Script Publishing — download.autopilotmonitor.com/agent/
|
||||
description: How the customer-facing PowerShell scripts reach the download alias, why publishing is decoupled from agent releases, and the guards that keep the published copy, the version badge and the portal's version oracle in agreement.
|
||||
resource: /.github/scripts/Publish-BootstrapScripts.ps1
|
||||
tags:
|
||||
- build
|
||||
- ci
|
||||
- release
|
||||
- bootstrap
|
||||
- download-alias
|
||||
timestamp: 2026-08-19T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
Four PowerShell scripts are served to customers from `https://download.autopilotmonitor.com/agent/`:
|
||||
|
||||
| Blob | Source | Consumed by |
|
||||
| --- | --- | --- |
|
||||
| `Install-AutopilotMonitor.ps1` | `scripts/Bootstrap/Install-AutopilotMonitor.ps1` | Intune platform script (customer uploads a copy); downloaded live on every device by the WDP bootstrap MSI |
|
||||
| `Install-AutopilotMonitor-Dev.ps1` | rendered from the same source | dev fleet |
|
||||
| `Test-ShouldBootstrapAgent.ps1` | `scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1` | customer dry run via `irm ... \| iex` |
|
||||
| `Grant-AutopilotMonitorAddOn.ps1` | `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` | optional Graph permission grant |
|
||||
|
||||
`Uninstall-AutopilotMonitor.ps1` is deliberately not published — nothing links it, and an
|
||||
unauthenticated uninstall script on the public download host is not a feature.
|
||||
|
||||
The alias is a Front Door route in front of the `agent` container; see
|
||||
[url-registry.md](url-registry.md) for the host registry and the migration to the alias.
|
||||
|
||||
# Publishing chain
|
||||
|
||||
`.github/scripts/Publish-BootstrapScripts.ps1` is the single owner. Two workflows call it
|
||||
with identical arguments:
|
||||
|
||||
* **`publish-scripts.yml`** — on every push to `main` touching `scripts/Bootstrap/**`,
|
||||
`scripts/CustomerSetup/**` or the publisher itself, plus `workflow_dispatch` with a
|
||||
`dry_run` input.
|
||||
* **`build-agent.yml`** — inside the `publish_as_stable` branch, after `version.json` is
|
||||
uploaded, so a release lands a set consistent with what is already live.
|
||||
|
||||
Before this split, the script blobs rotated **only** on a stable agent release. A bootstrap
|
||||
fix therefore sat unpublished until the next agent cutover — while the WDP MSI downloads the
|
||||
published copy on every enrolling device. Script publishing is now decoupled from the agent
|
||||
release cadence; the release path still publishes, so it can never fall behind either.
|
||||
|
||||
Both workflows share the concurrency group `agent-blob-publish` (`cancel-in-progress: false`).
|
||||
Both write `version.json`, and the publisher's `bootstrapVersion` read-modify-write must not
|
||||
interleave with a release writing the agent fields.
|
||||
|
||||
# Publisher steps
|
||||
|
||||
1. **Parse** `$ScriptVersion` from the bootstrap source.
|
||||
2. **Dev render** — literal substitution of the two `$AgentDownloadUrl` / `$VersionJsonName`
|
||||
defaults. A missing anchor literal is a hard failure: a silently un-substituted dev script
|
||||
would point the dev fleet at the stable agent.
|
||||
3. **Version-bump guard** — if the published bootstrap changed *behaviour* under an unchanged
|
||||
`$ScriptVersion`, the run aborts. Comment and formatting changes publish with a warning.
|
||||
See [Version oracles](#version-oracles).
|
||||
4. **Upload** every blob with `Cache-Control: no-cache` (they all rotate in place), mirrored
|
||||
fail-soft to the legacy storage account.
|
||||
5. **Reconcile the version oracles.**
|
||||
6. **Verify through the alias** — re-download each blob and compare SHA-256, six attempts
|
||||
20 s apart. A persistent mismatch means blobs were written but Front Door serves stale
|
||||
content, and the error names the `az afd endpoint purge` command.
|
||||
|
||||
## Deterministic publish form
|
||||
|
||||
Every blob is published as **CRLF, UTF-8 without BOM**, normalized by the publisher rather
|
||||
than taken from the working copy. Git normalizes line endings on checkout — a Windows runner
|
||||
yields CRLF, a `core.autocrlf=false` clone yields LF — so publishing raw working-copy bytes
|
||||
would emit different blobs for identical source and make every byte comparison, guard and
|
||||
verification alike, depend on where the workflow ran. CRLF is also what PS 5.1 expects on the
|
||||
device and what has always been published.
|
||||
|
||||
The ASCII-only rule for `scripts/Bootstrap/*.ps1` (enforced by the gates below) is unrelated
|
||||
but adjacent: PS 5.1 reads BOM-less files as ANSI and corrupts multi-byte characters.
|
||||
|
||||
## Version oracles
|
||||
|
||||
Three places state the bootstrap script version, and all three are derived from
|
||||
`$ScriptVersion` in the source:
|
||||
|
||||
* `version.json.bootstrapVersion` — read by the docs badge. Written by the publisher as a
|
||||
read-modify-write under `If-Match`, so a concurrent agent release keeps its own fields.
|
||||
`build-agent.yml` seeds the field when it builds the manifest so the published manifest is
|
||||
never briefly missing it; the publisher owns and re-asserts it afterwards.
|
||||
* `AdminConfiguration.LatestBootstrapV2ScriptVersion` — read by the portal to tell a customer
|
||||
their uploaded copy is outdated. **Only** the publisher writes it; `build-agent.yml` writes
|
||||
the `LatestAgentV2*` fields and nothing else. One writer per field.
|
||||
* The `Bootstrap script version: v<x>` marker the script logs, parsed out of captured platform
|
||||
script stdout by `utils/bootstrapVersion.ts`.
|
||||
|
||||
The version-bump guard exists because those three would otherwise keep asserting a version
|
||||
that no longer matches what customers download: a behaviour change under an unchanged version
|
||||
is invisible to every consumer, and customers decide from that version whether to re-upload
|
||||
their Intune copy.
|
||||
|
||||
"Changed" means changed **behaviour**, matching what the source file's own comment on
|
||||
`$ScriptVersion` already asks for ("bump on meaningful changes"). The guard compares a code
|
||||
fingerprint — the PowerShell token stream with comments, newlines and line continuations
|
||||
removed — so comment fixes, typo corrections in comments and reflow publish with a
|
||||
`::warning::` instead of failing. Tokenising rather than regex-stripping keeps a `#` inside a
|
||||
string literal from being mistaken for a comment.
|
||||
|
||||
# Gates
|
||||
|
||||
`.github/workflows/bootstrap-script-gates.yml` is a `workflow_call` workflow holding the
|
||||
checks once:
|
||||
|
||||
* Pester suite under **Windows PowerShell 5.1** — the runtime IME uses for platform scripts.
|
||||
* ASCII-only scan over `scripts/Bootstrap/*.ps1`.
|
||||
* `Publish-BootstrapScripts.ps1 -DryRun` — exercises the dev-render anchors and the
|
||||
version-bump guard, and reports per blob whether the alias is current, stale or missing.
|
||||
|
||||
Two callers: `pester.yml` on pull requests (reports, does not block the merge button), and
|
||||
`publish-scripts.yml` on pushes to `main`, where the gates job is a `needs:` dependency of the
|
||||
publish job and therefore **blocks** the upload. `pester.yml` deliberately has no push trigger
|
||||
— it would duplicate the gate run with no consequence.
|
||||
|
||||
# Operating it
|
||||
|
||||
* Dry run and drift check, no credentials needed for the read paths:
|
||||
`pwsh .github/scripts/Publish-BootstrapScripts.ps1 -DryRun`
|
||||
* Republish without a code change: `workflow_dispatch` on **Publish customer scripts** with
|
||||
`dry_run=false`.
|
||||
* Front Door serving stale content after a successful upload:
|
||||
`az afd endpoint purge --resource-group rg-autopilotmonitor-prd-gwc --profile-name autopilotmonitor-fd --endpoint-name apm-download --content-paths '/agent/*'`
|
||||
|
||||
# Citations
|
||||
|
||||
* `.github/scripts/Publish-BootstrapScripts.ps1`
|
||||
* `.github/workflows/publish-scripts.yml`, `.github/workflows/bootstrap-script-gates.yml`, `.github/workflows/pester.yml`
|
||||
* `.github/workflows/build-agent.yml` (the `publish_as_stable` branch of the upload step)
|
||||
* `src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1`
|
||||
* `src/Web/autopilot-monitor-web/utils/bootstrapVersion.ts`
|
||||
+16
-6
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Version Contract — Agent, Backend, MCP
|
||||
description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
title: Version Contract — Agent, Backend, MCP, Web
|
||||
description: One version shape for all shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
resource: /.github/scripts/Request-BuildNumber.ps1
|
||||
tags:
|
||||
- build
|
||||
@@ -13,9 +13,12 @@ timestamp: 2026-07-22T00:00:00+02:00
|
||||
|
||||
# Version Contract
|
||||
|
||||
Three components ship from this repository — the agent, the Functions backend and the
|
||||
MCP server. They share one version shape, one way of minting build numbers, and one
|
||||
manifest format, so that a version string means the same thing whoever emits it.
|
||||
Four components ship from this repository — the agent, the Functions backend, the
|
||||
MCP server and the web portal. They share one version shape, one way of minting build
|
||||
numbers, and one manifest format, so that a version string means the same thing
|
||||
whoever emits it. The web portal is the commit-only exception: a static export has no
|
||||
runtime that could report a `major.minor.build`, so it carries `commit` + `buildUtc`
|
||||
alone (no counter blob).
|
||||
|
||||
```
|
||||
version = <major>.<minor>.<build> major.minor curated by hand, build minted per build
|
||||
@@ -28,6 +31,7 @@ buildUtc = ISO-8601 build moment
|
||||
| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
|
||||
| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
|
||||
| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
|
||||
| Web portal | — (commit-only) | — | `GET /version.json` on the portal host |
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -77,7 +81,12 @@ exception: `build.ps1` produces uploadable artifacts and reserves a real number
|
||||
```
|
||||
|
||||
The MCP manifest adds `docsCommit`, because for that component "which build" and "which
|
||||
documentation" are separate questions.
|
||||
documentation" are separate questions. `versions/web.json` carries `component`, `commit`,
|
||||
`deployedUtc` and `runId` (no `version`/`buildUtc` — the served `/version.json` holds the
|
||||
build moment). The portal's own stamp is generated into `public/version.json` by
|
||||
`scripts/generate-version.js` (prebuild) from `GITHUB_SHA`, served with
|
||||
`Cache-Control: no-store`, and `deploy-web.yml` polls it until the deployed commit
|
||||
matches the pushed SHA before publishing the manifest.
|
||||
|
||||
Each is written **last and only after the deploy workflow has polled the component's
|
||||
health endpoint until it reports that exact version and commit**. A green deploy step
|
||||
@@ -104,5 +113,6 @@ remain, being the customer-facing integrity contract.
|
||||
|
||||
- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
|
||||
- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
|
||||
- `.github/workflows/deploy-web.yml`, `src/Web/autopilot-monitor-web/scripts/generate-version.js` — web stamp, verify, publish
|
||||
- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
|
||||
- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Autopilot-Monitor — Log Analytics workspace transformation (ingestion-time filter)
|
||||
//
|
||||
// Two ingestion-time filters. (2) Successful Azure SignalR REST dependencies emitted by the
|
||||
// HOST process for the SignalR output binding (SDKVersion rdddsc:*) — the worker-side
|
||||
// StorageDependencyFilterProcessor never sees those. Failed SignalR calls are kept.
|
||||
// (1) Drops the redundant HOST-side HTTP request record from AppRequests so that exactly one
|
||||
// canonical request row remains: the worker-side copy emitted by RequestTelemetryMiddleware
|
||||
// (Properties.Source == 'WorkerMiddleware', carries TenantId/UserId/CorrelationId and is
|
||||
// sampling-exempt per item). Non-HTTP host invocations (timer/queue triggers) have an empty
|
||||
// Url and are kept — the worker middleware never sees them.
|
||||
//
|
||||
// Why ingestion-time: the host request item is emitted by the Functions host process and
|
||||
// cannot be suppressed from worker code or host.json (verified 2026-06-09, see Program.cs).
|
||||
// Live numbers 2026-08-23 (7d): host copy 274k rows / 323 MB, worker copy 189k rows / 208 MB.
|
||||
//
|
||||
// Cost note: transformation filtering is free up to 50% of the table's incoming volume; the
|
||||
// portion beyond that (here ~11% of AppRequests) is billed at the (cheap) data-processing rate.
|
||||
//
|
||||
// Apply (two steps; the link is a CLI update = GET+PUT, which keeps retention/SKU intact):
|
||||
// az deployment group create -g rg-autopilotmonitor-prd-gwc \
|
||||
// --template-file infra/appinsights-workspace-transforms.bicep \
|
||||
// --query properties.outputs.dcrId.value -o tsv
|
||||
// az monitor log-analytics workspace update -g rg-autopilotmonitor-prd-gwc -n AutopilotMonitor \
|
||||
// --data-collection-rule <dcrId-from-above>
|
||||
//
|
||||
// Verify after ~20 min (ingestion latency):
|
||||
// AppRequests | where TimeGenerated > ago(15m) | where AppRoleName == 'autopilotmonitor-api-eu'
|
||||
// | summarize rows=count() by source=tostring(Properties.Source), hasUrl=isnotempty(Url)
|
||||
// → only (WorkerMiddleware, true) and ('', false) buckets remain.
|
||||
//
|
||||
// Rollback: az monitor log-analytics workspace update -g <rg> -n AutopilotMonitor
|
||||
// --set properties.defaultDataCollectionRuleResourceId=null (then delete the DCR)
|
||||
|
||||
@description('Log Analytics workspace that backs the workspace-based Application Insights resources.')
|
||||
param workspaceName string = 'AutopilotMonitor'
|
||||
|
||||
param location string = resourceGroup().location
|
||||
|
||||
resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
|
||||
name: workspaceName
|
||||
}
|
||||
|
||||
resource transforms 'Microsoft.Insights/dataCollectionRules@2023-03-11' = {
|
||||
name: 'dcr-autopilotmonitor-ws-transforms'
|
||||
location: location
|
||||
kind: 'WorkspaceTransforms'
|
||||
properties: {
|
||||
description: 'AppRequests: keep the enriched worker request record (Properties.Source == WorkerMiddleware) and non-HTTP host invocations (empty Url); drop the host HTTP duplicate.'
|
||||
destinations: {
|
||||
logAnalytics: [
|
||||
{
|
||||
workspaceResourceId: workspace.id
|
||||
name: 'ws'
|
||||
}
|
||||
]
|
||||
}
|
||||
dataFlows: [
|
||||
{
|
||||
streams: [ 'Microsoft-Table-AppRequests' ]
|
||||
destinations: [ 'ws' ]
|
||||
transformKql: 'source | where tostring(Properties.Source) == \'WorkerMiddleware\' or isempty(Url)'
|
||||
}
|
||||
{
|
||||
// Successful Azure SignalR REST calls (group add/remove per connection) are emitted by
|
||||
// the HOST process for the SignalR output binding (SDKVersion rdddsc:*), so the worker's
|
||||
// StorageDependencyFilterProcessor never sees them. Failed SignalR calls are kept.
|
||||
streams: [ 'Microsoft-Table-AppDependencies' ]
|
||||
destinations: [ 'ws' ]
|
||||
transformKql: 'source | where not(Success == true and Target endswith \'.service.signalr.net\')'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace has exactly one transformation DCR, activated by linking it as the workspace's
|
||||
// defaultDataCollectionRuleResourceId — deliberately NOT modelled here (see Apply above).
|
||||
|
||||
output dcrId string = transforms.id
|
||||
@@ -25,7 +25,7 @@
|
||||
// az deployment group create \
|
||||
// --resource-group <rg-name> \
|
||||
// --template-file infra/mcp-server.bicep \
|
||||
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
|
||||
// --parameters apiUrl=https://autopilotmonitor-api-eu.azurewebsites.net \
|
||||
// entraClientSecret=<secret-value>
|
||||
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.azurecontainerapps.io).
|
||||
//
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -34,5 +38,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "detection", "win32"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -36,5 +41,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "msi", "error-code"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"msi",
|
||||
"error-code"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -29,5 +33,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "dependency"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"dependency"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -36,8 +41,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "app_failed", "condition": "exists", "weight": 15 },
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "app_failed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "Performance monitoring shows disk free space dropped below 5 GB during enrollment. This can cause app installation failures, especially for large Win32 apps.\n\nIf app installations failed in the same session while free space was low, treat storage exhaustion as the likely contributing cause — the confidence score of this finding is raised accordingly. Verify the individual app failures for their concrete error codes before concluding storage was the only cause.",
|
||||
"remediation": [
|
||||
@@ -58,5 +71,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "disk-space", "critical"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"disk-space",
|
||||
"critical"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -29,5 +33,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "download", "timeout"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"download",
|
||||
"timeout"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -31,5 +35,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "reboot", "retry", "loop"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
"retry",
|
||||
"loop"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
"description": "Detects when the app tracking summary reports multiple failed app installations.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -16,10 +22,22 @@
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"explanation": "The app tracking summary reports 2 or more failed app installations during enrollment. This suggests a systemic issue rather than an isolated app failure.",
|
||||
"remediation": [
|
||||
@@ -32,5 +50,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "summary", "multiple-failures"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"summary",
|
||||
"multiple-failures"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -28,5 +32,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "performance", "slow-install", "warning"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"performance",
|
||||
"slow-install",
|
||||
"warning"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -34,7 +38,17 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Win32 app return codes in Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes" }
|
||||
{
|
||||
"title": "Win32 app return codes in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "enforcement", "exit-code", "unmapped", "resolved"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"enforcement",
|
||||
"exit-code",
|
||||
"unmapped",
|
||||
"resolved"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 90,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -69,8 +73,20 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes", "url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg" },
|
||||
{ "title": "Win32 app detection rules in Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules" }
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg"
|
||||
},
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "detection", "esp", "0x87d1041c", "intune"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"esp",
|
||||
"0x87d1041c",
|
||||
"intune"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -35,6 +39,51 @@
|
||||
"operator": "regex",
|
||||
"value": "^0x80073[cd]",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_esp_category",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "espCategory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_esp_errorcode",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "espErrorCode",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_top_candidate_package",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "topCandidatePackage",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_candidate_count",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "candidateCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_scan_confidence",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "confidence",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The ESP **{{espCategory}}/Apps** subcategory failed with HRESULT **{{espErrorCode}}** — an error from the AppX/MSIX deployment stack, i.e. a **Microsoft Store / MSIX app**, not a Win32 app. These apps install via the EnterpriseModernAppManagement CSP and never appear in the IME Win32 sidecar logs, which is why all tracked Win32/WinGet apps in this session can look green while ESP still reports an Apps failure.\n\nThe agent scanned the AppXDeploymentServer event log during the failure settle window and identified **{{topCandidatePackageName}}** (package `{{topCandidatePackage}}`) as the most likely culprit — its deployment error matches the ESP failure code ({{candidateCount}} candidate(s) total, confidence: {{confidence}}).\n\n**This is a correlation-based assessment, not a confirmed root cause** — validate against the AppXDeploymentServer/Operational event log on the device or in the diagnostics archive before acting.",
|
||||
@@ -65,8 +114,21 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes", "url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg" },
|
||||
{ "title": "Add Microsoft Store apps to Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft" }
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg"
|
||||
},
|
||||
{
|
||||
"title": "Add Microsoft Store apps to Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "appx", "msix", "store", "esp", "0x80073cf9"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"appx",
|
||||
"msix",
|
||||
"store",
|
||||
"esp",
|
||||
"0x80073cf9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-015",
|
||||
"title": "Required App Never Settled — ESP Completion Deferred",
|
||||
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the pending app eventually settled and the enrollment completed — a transient deferral is normal."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session failed explicitly — the app/ESP failure rules already cover that path; this rule targets the never-finishing case."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "completion_deferred",
|
||||
"source": "event_type",
|
||||
"eventType": "agent_trace",
|
||||
"dataField": "message",
|
||||
"operator": "contains",
|
||||
"value": "completion signal deferred",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "apps_pending",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "pending",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "pending_app_names",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "pendingNames",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "pending_app_names", "condition": "exists", "weight": 5 }
|
||||
],
|
||||
"explanation": "The Intune Management Extension wrote its user-session completion line, but the agent observed `{{pending}}` required app(s) still pending (`{{pendingNames}}`) and therefore **deferred the enrollment completion signal** — repeatedly, if this happened across several logons or reboots.\n\nA pending app that never transitions to downloading/installing and never fails is almost always an **assignment or requirement problem**, not an install error:\n\n- requirement rules that are never met on this device (OS/architecture/registry/script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app targeted as *required* for the user ESP that needs something unavailable during provisioning (e.g. a VPN client whose portal sign-in only happens after first logon),\n- user-targeted apps on a device where the user identity never fully registered (see the hybrid user-affinity rule if it also fired).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify and unblock the pending app",
|
||||
"steps": [
|
||||
"Check the app names listed in this finding's evidence (pendingNames) — these are the apps the ESP is waiting for.",
|
||||
"In Intune, review the app's requirement rules and dependencies; test them on the device (IntuneManagementExtension.log logs each requirement evaluation).",
|
||||
"Decide whether the app really must block the ESP: removing it from the ESP's 'blocking apps' list (or making it not-required during enrollment) unblocks completion immediately.",
|
||||
"If the app is user-targeted on a hybrid-joined device, verify the user's Entra affinity first — without a signed-in Entra user, user-targeted policy never applies."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["apps", "esp", "completion", "assignment", "pending"]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-016",
|
||||
"title": "Required Apps Never Started — ESP Apps Gate Starved",
|
||||
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:session_stalled"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "starved_apps_present",
|
||||
"source": "event_count",
|
||||
"eventType": "app_install_starved",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "many_starved_apps",
|
||||
"source": "event_count",
|
||||
"eventType": "app_install_starved",
|
||||
"operator": "count_gte",
|
||||
"value": "5",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appName",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_starved",
|
||||
"dataField": "appName",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "state",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_starved",
|
||||
"dataField": "state",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "many_starved_apps",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "appName",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "At least one **required** app assigned to the user ESP never started installing. The Intune Management Extension never moved it out of its initial state (`{{state}}`) — it did not download, did not install, and did not fail — while the ESP AccountSetup apps gate was waiting on it. First app observed: `{{appName}}`.\n\nEvery starved app has its own `app_install_starved` timeline event carrying `appId`, `appName` and `state` — open the timeline and filter on that event type for the complete list.\n\nThis is a **delivery** problem, not an install error, and it is invisible in the Intune console: the app simply never appears. Typical causes:\n\n- far more required apps targeted at the ESP than the gate can service in the available window,\n- requirement rules that are never met on this device (OS / architecture / registry / script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app that needs a context unavailable during provisioning (e.g. a VPN client waiting on a portal sign-in),\n- a mid-ESP reboot that cut the gate short — check whether the mid-ESP reboot advisory also fired for this session.\n\nNote that this finding is independent of the session verdict. A session can be marked **Succeeded** and still be missing every app listed here.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Shrink the ESP blocking set",
|
||||
"steps": [
|
||||
"Open the Enrollment Status Page profile and review the blocking app list — every required app there must finish before the user reaches the desktop.",
|
||||
"Move apps that do not have to be present at first logon from *required* to *available*, or remove them from the ESP's blocking apps selection.",
|
||||
"Prefer a small, deliberately chosen blocking set (security agent, VPN, management tooling) over the full application catalogue."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Unblock the individual apps",
|
||||
"steps": [
|
||||
"Collect the app names from the `app_install_starved` timeline events in this session.",
|
||||
"For each app, review its requirement rules and dependencies in Intune — IntuneManagementExtension.log records every requirement evaluation.",
|
||||
"Verify the assignment actually targets the signed-in user (user-targeted apps need an established Entra user affinity; check the hybrid user-affinity finding if it also fired).",
|
||||
"Re-run an enrollment and confirm the apps now reach a terminal state before the ESP exits."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rule out a truncated gate",
|
||||
"steps": [
|
||||
"Check whether a mid-ESP reboot was observed for this session — a forced restart during AccountSetup cuts the apps gate short and can starve everything still queued.",
|
||||
"If so, reassign the reboot-forcing device policies to user groups so the restart lands after AccountSetup."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Win32 app requirement rules",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/apps/apps-win32-add"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
"starvation",
|
||||
"assignment",
|
||||
"blocking",
|
||||
"delivery-gap"
|
||||
]
|
||||
}
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -30,8 +35,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "app_install_failed", "condition": "count >= 2", "weight": 15 },
|
||||
{ "signal": "enrollment_failed", "condition": "count >= 1", "weight": 10 }
|
||||
{
|
||||
"signal": "app_install_failed",
|
||||
"condition": "count >= 2",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "count >= 1",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "A proxy (explicit or PAC-based) is configured on this device and app content downloads failed (Delivery Optimization timeout or download content error). Proxies can block or throttle downloads from *.delivery.mp.microsoft.com and other Intune CDN endpoints.\n\nThis is a common root cause when app downloads fail consistently on proxy-connected corporate networks. Note this rule only fires when the failure is download-related — generic app install errors behind a proxy do not trigger it.",
|
||||
@@ -54,5 +67,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["correlation", "apps", "proxy", "network", "download", "root-cause"]
|
||||
"tags": [
|
||||
"correlation",
|
||||
"apps",
|
||||
"proxy",
|
||||
"network",
|
||||
"download",
|
||||
"root-cause"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 55,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -38,8 +42,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "app_failed", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "app_failed",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"explanation": "Memory usage exceeded 90% in at least 3 performance snapshots during this enrollment — sustained pressure, not a single transient spike. Persistent high memory usage can cause app installation failures, system slowdowns, and ESP timeouts, especially on devices with 8 GB RAM installing several apps concurrently.",
|
||||
"remediation": [
|
||||
@@ -53,5 +65,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["device", "memory", "performance"]
|
||||
"tags": [
|
||||
"device",
|
||||
"memory",
|
||||
"performance"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -52,5 +56,11 @@
|
||||
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
|
||||
}
|
||||
],
|
||||
"tags": ["device", "os-version", "end-of-support", "windows-11", "windows-10"]
|
||||
"tags": [
|
||||
"device",
|
||||
"os-version",
|
||||
"end-of-support",
|
||||
"windows-11",
|
||||
"windows-10"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:windows_update_failed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"confidenceThreshold": 60,
|
||||
@@ -65,8 +70,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "reboot_pending", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "reboot_pending",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "A Windows Update failed to install during enrollment: **{{updateTitle}}** (error {{hresult}} {{hresultSymbol}}). Windows can install quality/cumulative updates during OOBE (the \"Getting updates\" screen and the ESP \"Install Windows quality updates during OOBE\" option). When one of these fails mid-enrollment it can leave the device in an inconsistent state, force an unexpected reboot, or block the Enrollment Status Page — a failure mode that is otherwise only discoverable by deep manual digging on the device.",
|
||||
"remediation": [
|
||||
@@ -87,5 +100,10 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "windows-update", "oobe", "servicing"]
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"servicing"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -39,7 +43,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "reboot_pending", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "reboot_pending",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"explanation": "One or more Windows updates installed during enrollment (e.g. **{{updateTitle}}**). This is normal when Windows Update runs during OOBE, but it extends enrollment time and can trigger a reboot mid-ESP. Track it when correlating enrollment duration or reboot count with a specific cumulative update, or when a device behaves differently after a particular KB landed during provisioning.",
|
||||
"remediation": [
|
||||
@@ -53,5 +61,9 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "windows-update", "oobe"]
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -47,7 +51,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "wu_channel_blind", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "wu_channel_blind",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "The OS build changed from **{{previousBuild}}** to **{{currentBuild}}** across a reboot during enrollment — a Windows update was installed mid-provisioning. This is normal when quality updates run during OOBE, but it adds a reboot and extends enrollment time. If a windows_update_channel_census event is also present, the update installed via a servicing path the Windows Update watcher does not capture yet — its EventID histogram tells which IDs to enable.",
|
||||
"remediation": [
|
||||
@@ -61,5 +69,10 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "windows-update", "oobe", "reboot"]
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"reboot"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts. Knowledge source: docs/agent/autopilot-ztd-diagnostics.md.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 55,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -55,5 +59,12 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "ntp", "clock-skew", "tpm", "esp", "known-issue"]
|
||||
"tags": [
|
||||
"device",
|
||||
"ntp",
|
||||
"clock-skew",
|
||||
"tpm",
|
||||
"esp",
|
||||
"known-issue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-008",
|
||||
"title": "Device Clock Wrong During Enrollment — Jump or Sustained Offset",
|
||||
"description": "The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "clock_jump",
|
||||
"source": "clock_skew",
|
||||
"skewMetric": "clock_jump",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "sustained_offset",
|
||||
"source": "clock_skew",
|
||||
"skewMetric": "sustained_offset",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [],
|
||||
"explanation": "This device's {{skewSummary}} (five-minute threshold). A clock that is off by five or more minutes breaks time-sensitive enrollment mechanisms: Kerberos tolerates at most 5 minutes of skew (Hybrid Join), Entra ID token validation allows about 5 minutes on token lifetimes, and freshly issued SCEP/PKCS certificates can be rejected as \"not yet valid\" when the clock is more than ~10 minutes behind the CA.\n\nUnlike ANALYZE-DEV-007 (a one-shot NTP comparison at agent startup), this finding is measured across the whole session against the server's receive time — it also catches devices where the NTP check could not run (UDP 123 blocked) or where the clock changed after startup (time sync correcting a wrong RTC, Windows auto-timezone, manual change).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Fix the device clock and time source",
|
||||
"steps": [
|
||||
"Check the BIOS/RTC clock and the CMOS battery if the wrong time recurs on the same hardware.",
|
||||
"At OOBE, run 'w32tm /resync /force' (Shift+F10 command prompt) to sync with time.windows.com.",
|
||||
"Ensure UDP port 123 to time.windows.com is open in the deployment network.",
|
||||
"Review 'Set time automatically' and 'Set time zone automatically' behavior on the deployment image — automatic timezone changes mid-enrollment show up as clock jumps."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Windows Autopilot known issues — device provisioning can fail (clock skew)",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/known-issues"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Windows Autopilot requirements — Networking (NTP)",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/requirements?tabs=networking"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"clock-skew",
|
||||
"clock-jump",
|
||||
"time-sync",
|
||||
"kerberos",
|
||||
"certificates",
|
||||
"known-issue"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-009",
|
||||
"title": "Battery Critically Low During Enrollment",
|
||||
"description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:power_state_change"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "battery_threshold_15",
|
||||
"source": "event_data",
|
||||
"eventType": "power_state_change",
|
||||
"dataField": "thresholdPercent",
|
||||
"operator": "equals",
|
||||
"value": "15",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The device's battery fell below **{{thresholdPercent}}%** while the enrollment was running on battery power. If the battery drains completely mid-enrollment, the device is left in a half-provisioned state — apps, policies, or the user profile may be incomplete, and in most cases the device has to be reset and re-enrolled. The agent detected the threshold crossing live via a Windows power-status notification; the exact percentage at that moment is recorded on the matched power_state_change event in the session timeline.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Secure power for the running enrollment",
|
||||
"steps": [
|
||||
"Connect the device to AC power immediately — the enrollment continues normally once power is stable.",
|
||||
"If the device shut down before completing, check whether the session reached the desktop; if not, reset the device (Autopilot reset or reimage) and re-enroll it plugged in.",
|
||||
"For technician/White Glove flows, make AC power part of the bench checklist — pre-provisioning on battery is the most common source of this alert.",
|
||||
"If this fires across many devices of one model, review that model's battery health (the hardware_spec event carries design vs. full-charge capacity)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
"battery",
|
||||
"environment"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-010",
|
||||
"title": "Enrollment Switched From AC to Battery Power",
|
||||
"description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:power_state_change"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 85,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "switched_to_battery",
|
||||
"source": "event_data",
|
||||
"eventType": "power_state_change",
|
||||
"dataField": "transition",
|
||||
"operator": "equals",
|
||||
"value": "ac_to_battery",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The device switched from AC to battery power mid-enrollment. Enrollments are power-hungry — app downloads, Windows Update and disk encryption drain a battery quickly, and Windows may also throttle CPU and network on battery, slowing the enrollment down. The battery percentage at the moment of the switch is recorded on the matched power_state_change event in the session timeline; if the device stayed on battery, look there for subsequent threshold events (50/30/15%) — a later battery_to_ac event means power was restored.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Restore AC power",
|
||||
"steps": [
|
||||
"Reconnect the device to AC power; the enrollment continues normally.",
|
||||
"Check the session timeline for a subsequent battery_to_ac event (power restored) or threshold_crossed events (battery draining) to judge urgency.",
|
||||
"If this alert is frequent in a deployment area, check docking stations/USB-C hubs for loose power delivery — a flapping dock is a common cause.",
|
||||
"Make 'plugged in during setup' part of the end-user/technician enrollment instructions."
|
||||
]
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
"battery",
|
||||
"environment"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-012",
|
||||
"title": "Device Entered Standby During Enrollment",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:system_sleep_episode"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "slept_during_enrollment",
|
||||
"source": "event_data",
|
||||
"eventType": "system_sleep_episode",
|
||||
"dataField": "durationSeconds",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Keep the device awake during enrollment",
|
||||
"steps": [
|
||||
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
|
||||
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
|
||||
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
|
||||
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
|
||||
]
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
"standby",
|
||||
"sleep",
|
||||
"environment"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"preconditions": [
|
||||
@@ -30,9 +30,35 @@
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "timeoutHours",
|
||||
"source": "event_data",
|
||||
"eventType": "session_timeout",
|
||||
"dataField": "timeoutHours",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "desktop_was_reached",
|
||||
"source": "event_type",
|
||||
"eventType": "desktop_arrived",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "all_tracked_apps_completed",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "isAllCompleted",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.",
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the silent session",
|
||||
@@ -43,6 +69,13 @@
|
||||
"If the device is reachable, collect the agent + IME logs and re-run enrollment, correlating with the timeout timestamp",
|
||||
"If many sessions time out in the same window, check for a tenant-wide network/connectivity issue or an Intune service incident"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Desktop reached and apps done, but no completion",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run dsregcmd /status — 'SSO State: AzureAdPrt' must be YES; NO means the user never registered with Entra and the Account Setup ESP will loop",
|
||||
"Verify Entra Connect syncs both the computer object (userCertificate) and the signing-in user, and that the user's UPN suffix is a verified Entra domain"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["enrollment", "timeout", "stalled"]
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:modern_deployment_error"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
@@ -75,5 +80,12 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["enrollment", "tpm", "attestation", "self-deploying", "pre-provisioning", "known-issue"]
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"tpm",
|
||||
"attestation",
|
||||
"self-deploying",
|
||||
"pre-provisioning",
|
||||
"known-issue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
@@ -74,5 +78,11 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["enrollment", "mdm", "known-issue", "entra-id", "hybrid-join"]
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"mdm",
|
||||
"known-issue",
|
||||
"entra-id",
|
||||
"hybrid-join"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276). Knowledge source: docs/agent/autopilot-ztd-diagnostics.md (error-code map).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 25,
|
||||
"confidenceThreshold": 60,
|
||||
@@ -59,10 +63,26 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "e_fail_esp_failure", "condition": "exists", "weight": 40 },
|
||||
{ "signal": "e_fail_esp_advisory", "condition": "exists", "weight": 35 },
|
||||
{ "signal": "e_fail_modern_deployment", "condition": "exists", "weight": 40 },
|
||||
{ "signal": "e_fail_error_detected", "condition": "exists", "weight": 35 }
|
||||
{
|
||||
"signal": "e_fail_esp_failure",
|
||||
"condition": "exists",
|
||||
"weight": 40
|
||||
},
|
||||
{
|
||||
"signal": "e_fail_esp_advisory",
|
||||
"condition": "exists",
|
||||
"weight": 35
|
||||
},
|
||||
{
|
||||
"signal": "e_fail_modern_deployment",
|
||||
"condition": "exists",
|
||||
"weight": 40
|
||||
},
|
||||
{
|
||||
"signal": "e_fail_error_detected",
|
||||
"condition": "exists",
|
||||
"weight": 35
|
||||
}
|
||||
],
|
||||
"explanation": "This is a **Microsoft Entra hybrid join** deployment that hit error **0x80004005** — a documented, build-dependent Autopilot known issue where hybrid deployments time out.\n\nThe issue is fixed by Windows cumulative updates:\n\n- Windows 11 **25H2**: KB5065789 — fixed from build **26200.6725**\n- Windows 11 **24H2**: KB5065426 — fixed from build **26100.6584**\n- Windows 11 **23H2**: KB5070312 — fixed from build **22631.6276**\n\nCompare this session's OS build (see the Session Info card / os_info event) against the fixed-build threshold for its version. If the device booted with an older build, updating the deployment image (or installing quality updates during OOBE) resolves the timeout. Note that 0x80004005 is a generic failure code (E_FAIL) — if the build is already at or above the fixed threshold, investigate the Intune Connector for Active Directory and domain-controller connectivity instead.",
|
||||
"remediation": [
|
||||
@@ -83,5 +103,11 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["enrollment", "hybrid-join", "known-issue", "windows-update", "esp"]
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"hybrid-join",
|
||||
"known-issue",
|
||||
"windows-update",
|
||||
"esp"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 50,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -20,8 +24,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "long_esp", "condition": "phase_duration > 1800", "weight": 40 },
|
||||
{ "signal": "very_long_esp", "condition": "phase_duration > 3600", "weight": 15 }
|
||||
{
|
||||
"signal": "long_esp",
|
||||
"condition": "phase_duration > 1800",
|
||||
"weight": 40
|
||||
},
|
||||
{
|
||||
"signal": "very_long_esp",
|
||||
"condition": "phase_duration > 3600",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The ESP DeviceSetup phase took more than 30 minutes. This indicates a blocking app is stuck installing, downloading very slowly, or has entered a retry loop. A duration beyond 60 minutes raises the confidence further — at that point the enrollment is almost certainly held by a single stuck blocking item rather than ordinary slowness.",
|
||||
@@ -35,5 +47,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["esp", "blocking", "timeout", "duration"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"blocking",
|
||||
"timeout",
|
||||
"duration"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -54,5 +58,10 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["esp", "certificates", "subcategory", "silent-failure"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"certificates",
|
||||
"subcategory",
|
||||
"silent-failure"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:session_stalled"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
@@ -47,8 +52,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "multiple_reboot_uris", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "omadm_reboot_flag_set", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
@@ -81,5 +94,12 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["esp", "reboot", "coalescing", "second-sign-in", "policy-attribution", "user-experience"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"reboot",
|
||||
"coalescing",
|
||||
"second-sign-in",
|
||||
"policy-attribution",
|
||||
"user-experience"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 85,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -71,8 +75,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "retry", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "likelyCulpritApps", "condition": "exists", "weight": 5 }
|
||||
{
|
||||
"signal": "retry",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "likelyCulpritApps",
|
||||
"condition": "exists",
|
||||
"weight": 5
|
||||
}
|
||||
],
|
||||
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
|
||||
"remediation": [
|
||||
@@ -86,5 +98,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["esp", "recovery", "try-again", "retry", "continue-anyway", "enrollment"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"recovery",
|
||||
"try-again",
|
||||
"retry",
|
||||
"continue-anyway",
|
||||
"enrollment"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
"category": "identity",
|
||||
"isCommunity": true,
|
||||
"enabled": false,
|
||||
"version": "1.2.0",
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"version": "1.3.0",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -50,7 +54,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["identity", "certificates", "machine-store", "subject-check"],
|
||||
"tags": [
|
||||
"identity",
|
||||
"certificates",
|
||||
"machine-store",
|
||||
"subject-check"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "cert_subject",
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -81,5 +85,10 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [],
|
||||
"tags": ["identity", "security", "local-admin", "bypass-detection"]
|
||||
"tags": [
|
||||
"identity",
|
||||
"security",
|
||||
"local-admin",
|
||||
"bypass-detection"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
@@ -31,7 +35,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "kev_matches", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "kev_matches",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 80,
|
||||
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
|
||||
@@ -64,5 +72,11 @@
|
||||
"url": "https://nvd.nist.gov/"
|
||||
}
|
||||
],
|
||||
"tags": ["vulnerability", "cve", "kev", "critical", "security"]
|
||||
"tags": [
|
||||
"vulnerability",
|
||||
"cve",
|
||||
"kev",
|
||||
"critical",
|
||||
"security"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-004",
|
||||
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "user_desktop_reached",
|
||||
"source": "event_type",
|
||||
"eventType": "desktop_arrived",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 2", "weight": 20 },
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
|
||||
{ "signal": "aad_placeholder_user_detected", "condition": "count >= 3", "weight": 10 }
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
|
||||
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix the identity pipeline",
|
||||
"steps": [
|
||||
"Entra Connect: confirm BOTH the computer OU and the user OU are in the sync scope, and the computer object's userCertificate attribute is being exported.",
|
||||
"Verify the user's UPN suffix is a verified domain in Entra ID (no .local / mismatched suffix).",
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Address VPN and Windows Hello dependencies",
|
||||
"steps": [
|
||||
"If a VPN is required to reach a domain controller, it must be established before or at the logon screen (machine tunnel / pre-logon tunnel). A user-context tunnel that starts after sign-in is too late for the hybrid PRT flow.",
|
||||
"If you deploy Windows Hello for Business to hybrid-joined devices, use cloud Kerberos trust — it removes the on-premises certificate/key-trust dependencies that commonly stall Hello on hybrid devices.",
|
||||
"If you do not use Windows Hello, disable it explicitly via a device-scoped Intune policy instead of leaving it unconfigured — the enrollment then resolves the Hello step deterministically instead of waiting for a wizard that never comes.",
|
||||
"Avoid feature/quality updates mid-enrollment where possible — an OS update with forced reboots during enrollment aggravates this pattern. Consider a short Windows Update deferral covering the enrollment window.",
|
||||
"For hybrid deployments, pre-provisioning (White Glove) materially shrinks the user-facing phase: the device phase runs on a known-good technician network, and the user phase reduces to sign-in, user ESP, and Hello."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — stuck in Account Setup (no user affinity)",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-stuck-in-account-setup"
|
||||
},
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot hybrid-joined devices",
|
||||
"url": "https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-hybrid-join-windows-current"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
|
||||
}
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -28,7 +32,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "error_code_present", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "error_code_present",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.",
|
||||
@@ -44,5 +52,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "office", "microsoft-365-apps", "click-to-run", "install-failure"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"office",
|
||||
"microsoft-365-apps",
|
||||
"click-to-run",
|
||||
"install-failure"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -83,10 +87,26 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "ca2023_notfound", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "db_ca2023_firmware_missing", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "kek_2023_missing", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "ca2023_notfound",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "ca2023_error",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "db_ca2023_firmware_missing",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "kek_2023_missing",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
|
||||
"remediation": [
|
||||
@@ -112,5 +132,11 @@
|
||||
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "certificate", "ca2023", "compliance"]
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"certificate",
|
||||
"ca2023",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
@@ -45,5 +49,11 @@
|
||||
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "autologon", "winlogon", "credentials", "plaintext-password"]
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"credentials",
|
||||
"plaintext-password"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -55,7 +59,13 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "autologon", "winlogon", "kiosk", "allow-list"],
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"kiosk",
|
||||
"allow-list"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_autologon_users",
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -34,5 +38,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["security", "provisioning-package", "ppkg", "allow-list", "tamper"]
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
"ppkg",
|
||||
"allow-list",
|
||||
"tamper"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -41,7 +45,13 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["security", "provisioning-package", "ppkg", "allow-list", "template"],
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
"ppkg",
|
||||
"allow-list",
|
||||
"template"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_packages",
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -32,5 +36,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["security", "provisioning-package", "ppkg", "truncated", "coverage"]
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
"ppkg",
|
||||
"truncated",
|
||||
"coverage"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
@@ -41,7 +45,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
@@ -74,5 +82,11 @@
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"firmware",
|
||||
"uefi",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
|
||||
+811
-37
File diff suppressed because it is too large
Load Diff
+21
@@ -128,6 +128,27 @@
|
||||
"oobe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-006",
|
||||
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": [
|
||||
"device",
|
||||
"entra",
|
||||
"join",
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-ENROLL-001",
|
||||
"title": "MDM enrollment diagnostic events",
|
||||
|
||||
+134
-42
@@ -4,12 +4,23 @@
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -139,6 +150,14 @@
|
||||
"description": "Captures the install attempt number for the current app. IME logs 'Execute retry 0' for the first attempt and increments on each retry. Reported as attempt+1 so retry 0 becomes AttemptNumber=1 (human-friendly: first attempt).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DECRYPT-FAILED",
|
||||
"category": "currentPhase",
|
||||
@@ -150,9 +169,9 @@
|
||||
{
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -168,15 +187,15 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -217,9 +236,9 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -236,9 +255,9 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -266,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -293,6 +312,14 @@
|
||||
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-ESP-PHASE",
|
||||
"category": "always",
|
||||
@@ -304,9 +331,9 @@
|
||||
{
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -320,6 +347,33 @@
|
||||
"description": "Captures the installer exit code (lpExitCode) for the current app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-HRESULT",
|
||||
"category": "currentPhase",
|
||||
@@ -377,25 +431,25 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -422,6 +476,17 @@
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SESSION-CHANGE",
|
||||
"category": "always",
|
||||
@@ -435,24 +500,24 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
|
||||
"enabled": true
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-2",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
|
||||
"enabled": true
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-3",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current.",
|
||||
"enabled": true
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-4",
|
||||
@@ -478,24 +543,24 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
|
||||
"enabled": true
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DETECTED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-FILTERS",
|
||||
@@ -529,8 +594,8 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STARTED",
|
||||
@@ -540,6 +605,17 @@
|
||||
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STOP-SIGNAL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received stop signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SUBGRAPH",
|
||||
"category": "currentPhase",
|
||||
@@ -548,6 +624,14 @@
|
||||
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-TOKEN-FAILURE",
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-TOKEN-SUCCESS",
|
||||
"category": "always",
|
||||
@@ -564,24 +648,24 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from the Processing app log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-USER-SESSION-COMPLETED",
|
||||
@@ -607,6 +691,14 @@
|
||||
"description": "Captures the Win32 app ESP state (e.g., Installed, InProgress) for a specific app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WINGET-ERROR",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"action": "updateStateError",
|
||||
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WINGET-INSTALLING-1",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-006",
|
||||
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": ["device", "entra", "join", "hybrid"]
|
||||
}
|
||||
+32
-1
@@ -250,6 +250,37 @@
|
||||
"C:\\Install\\Log"
|
||||
],
|
||||
"blockedFilePrefixes": [
|
||||
"C:\\Users"
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config"
|
||||
],
|
||||
"blockedCommandPatterns": [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000,
|
||||
"blockedInterimTriggerEventTypes": [
|
||||
"performance_snapshot",
|
||||
"agent_metrics_snapshot",
|
||||
"download_progress",
|
||||
"network_state_change",
|
||||
"network_connectivity_check",
|
||||
"log_entry",
|
||||
"agent_trace",
|
||||
"stall_probe_check"
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user