workflow_dispatch-only, mirroring the agent release contract: validate
the runner script (PS parse + ASCII-only, it runs as SYSTEM on every
enrolling device), wix 5 build, upload versioned + stable blob names to
the agent container (no-cache, no legacy mirror - the MSI channel never
existed there), HEAD-verify the public alias serves the fresh bytes,
Sigstore provenance attestation. MSI version stays source-controlled in
Package.wxs - bumped to 1.0.2 (runner: encoding fix + bootstrap-msi.log
rename). Customer download:
https://download.autopilotmonitor.com/agent/AutopilotMonitor-Bootstrap.msi
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent log directory mixed three conventions (agent_20260817.log,
bootstrap_agent.log, msi-bootstrap.log) while everything else in the
diagnostics ZIP is already kebab-case. All log files now follow
<component>-<content>.log:
agent.log (was agent_YYYYMMDD.log; segments agent-NNN.log)
bootstrap-script.log (was bootstrap_agent.log; script v2.4)
bootstrap-msi.log (was msi-bootstrap.log)
crash-<utc>.log (was crash_<utc>.log)
gather-rules-debug.log (was gather_rules_debug.log)
ime-pattern-matches.log (was ime_pattern_matches.log)
The date is dropped from the main log: the agent only lives for one
enrollment, and date rotation split the chronology at local midnight -
which can fall mid-enrollment while the clock is still on the OOBE
default timezone. The existing 50MB size rotation stays as the growth
bound. Legacy dated files are ignored by the rotation probe; crash
detection still picks up legacy crash_*.log once so an in-place
self-update cannot lose a pending crash classification.
Diag collectors glob *.log and need no change. MCP zip-map and docs
note the legacy names for archives from pre-rename agents. Settings UI
hint paths updated. MSI rebuild + bootstrap script publish (v2.4) +
MCP redeploy required to ship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Intune LOB requires a higher ProductVersion to accept a re-upload. Also
includes the runner logging fix: PS 5.1 *>> redirection writes UTF-16LE
into the otherwise ANSI msi-bootstrap.log - route through Add-Content for
a single encoding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Still needed: the skill extracts each MSI here (msiexec /a payload for grepping
in binaries); only decompilates go to the ime-decompiles repo and MSIs to the
ime-archive blob container.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P2 of the 2026-08-17 IME code audit. IME log wording is an unstable
contract (audit found 9 silently dead patterns, reworded lines, quote
drift, [DO TEL] removal); the registry is IME's internal product
contract - the ESP page and Intune reporting consume it.
ImeRegistryAppStateObserver + ImeRegistryAppStateHost (always-on
observability, no DecisionEngine coupling):
- one recursive RegistryWatcher on HKLM\...\IntuneManagementExtension
covers Win32Apps (EnforcementStateMessage JSON, ExitCode, Intent),
EspTrackingWin32Apps and SideCarPolicies\StatusServiceReports;
2 s debounced snapshot-and-diff + 60 s periodic fallback tick
(poll-only degraded mode if the watcher fails to arm)
- silent startup baseline: Win32Apps keys survive re-enrollments,
pre-existing state never replays as fresh events
- registry_app_state (Info, state-change-only, 200/session cap with
one-shot cap notice)
- app_state_reconciliation (Warning, once per app, immediate upload):
a changed entry terminal for >= 90 s is compared against the
log-derived AppPackageState - divergence is the built-in
log-pattern drift alarm (registry = truth, logs = narrative)
Event types dual-registered (Constants + MCP resource-catalog + shared
manifests regenerated). OKF concept doc agent/registry-app-state-pillar.md
incl. WDP Paket-3 reuse notes. Tests: pure parse/diff/divergence logic +
tick flow via snapshot override (baseline silence, emission, dwell,
fire-once, cap). All suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P0b/P1 of the 2026-08-17 IME code audit (agent release required):
- Tail AppActionProcessor.log (+ rollover glob) in ImeLogTracker and the
--run-ime-matching CLI. IME routes every [Win32App][ActionProcessor]
line there (enforcement decision engine), so IME-NO-ACTION and
IME-NOT-TARGETED-1/2 could never fire before, and GRS skips were
invisible. Live session 0fe2c562 carried 16 no-action and 64
classification lines in that file.
- New patterns (wordings verified in decompiled IME 1.97 AND 1.104):
IME-GRS-SKIP ("is in GRS. The app will not be enforced." -> Postponed;
THE canonical silent non-install reason), IME-GRS-CLEARED (early
subgraph termination, retry next check-in -> Postponed),
IME-TOKEN-FAILURE ("Failed to get AAD token ... errorCode = N").
- New action imeTokenFailure -> ime_token_failure Warning event (new
EventType: Constants + MCP resource-catalog + shared manifests
regenerated). Tracker dedups per distinct errorCode; included in
the historic-replay guard so stale lines from a previous enrollment
cannot emit a misleading warning. Complements IME-TOKEN-SUCCESS:
an auth outage previously left check-ins silently doing nothing.
Tests: adapter emission + unknown-code + tracker dedup; known-actions
catalog extended; all suites green (Functions 4262, Agent 2252, web 762).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IME 1.104 no longer logs the [DO TEL] line whose JSON carried the
DO-reported download duration - the Download Progress card lost all
times (per-app and total).
Durations now fall back to the observed event window (first download
event -> first completion-marking event) whenever the DO-reported value
is absent, and completed items show "in <duration>" inline. Works
retroactively for already-stored sessions, no agent release required;
the DO-reported value still wins when present (older IME versions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When RecordImeVersionAsync reports a genuinely new IME version (~monthly),
EventIngestProcessor now also enqueues an ime-msi-archive job. The worker
(QueuePollingWorker, heartbeat, pause on ImeMsiArchivingEnabled=false)
downloads the installer and stores it write-once (If-None-Match:*) in the
permanent ime-archive container ({version}/IntuneWindowsAgent.msi +
provenance.json), then merges MsiArchiveStatus/BlobPath/Sha256/Bytes/SourceUrl
onto the ImeVersionHistory row - so every fleet-observed IME build stays
available for decompilation/diffing after Microsoft's versionless CDN moves on.
The download URL comes from agent telemetry (msiDownloadUrl enrichment) and is
treated as untrusted: https-only *.manage.microsoft.com allowlist with exact
filename match, canonical versionless fallback, no redirects, size cap
(MaxImeMsiDownloadSizeMB, default 250, preflight + mid-stream) enforced by a
SHA-256-hashing pass-through stream. Archiver never throws; the worker retries
only transient statuses (download/timeout/error), permanent rejections
complete with their Failed:* status persisted.
Admin settings ImeMsiArchivingEnabled (default true) + MaxImeMsiDownloadSizeMB
via the full 4-file web chain; archive fields on the version-history API are
GA-only (non-GA projection unchanged, redaction tests extended). Shared
manifests regenerated. Tests: archiver status branches via recording subclass
(allowlist, caps, 409 idempotency, cancellation propagation), config defaults.
Backend suite 4262 green, web tsc + vitest 762 green, solution 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The EnterpriseDesktopAppManagement CSP records every MSI it enforces under
HKLM\SOFTWARE\Microsoft\EnterpriseDesktopAppManagement\<SID>\MSI\{ProductCode},
including CurrentDownloadUrl and ProductVersion. New ImeMsiInstallSourceProbe
resolves the entry matching the IME version parsed from the IME log (normalized
ProductVersion match first, IntuneWindowsAgent.msi filename fallback) and the
adapter attaches msiDownloadUrl / msiProductCode / msiProductVersion /
msiMatchedBy to the ime_agent_version event - answering which CDN/AFD endpoint
delivered exactly that IME build. Fail-soft, cached per version, additive keys
only (no new event type). Tests: probe selection/normalization + adapter
enrichment; assembly-wide probe default keeps dev machines deterministic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent-log snapshot is zipped BEFORE packaging finishes, so packaging
problems (blocked paths, silent no-match patterns, locked files, evtx
export errors) were invisible in the uploaded archive - twice this made a
missing evtx undiagnosable (sessions a11102f4/3ae7528b). Every packaging
decision is now recorded in package-manifest.txt inside the ZIP: ADDED,
NO MATCH, BLOCKED (path guard), SKIPPED (caps/reparse/no user session),
FAILED (copy), EVTX EXPORTED/ERROR/TIMEOUT, plus each configured
additional path with its expanded folder+pattern. Written unconditionally
so absence of the file identifies a pre-feature archive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The %4-encodes-slash file naming is a convention, not a contract: the
Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider
channel writes plain BootstrapperAgentServiceLogProvider.evtx. Deriving the
channel from the file name therefore produced an invalid wevtutil target
(and the wrong advice to rename the tenant diag path cost a WDP test
cycle). The channel is now resolved by scanning registered channels for a
matching LogFilePath, with the %4 convention only as fallback. A failed
export no longer skips the file - it falls through to the raw copy, which
succeeds for inactive/archived evtx files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live validation against session 0fe2c562 (IME 1.104.102, first session on
the repaired pattern set) showed all report-delta patterns at zero: IME
1.104 logs the delta JSON with single quotes ({"EnforcementState":...)
where older versions logged doubled quotes ({""EnforcementState"":...).
Every literal quote in the seven delta patterns (DETECTED-INSTALLED-1,
ENFORCEMENT-SUCCESS, ERROR-DOWNLOAD, ERROR-REPORT, NOT-APPLICABLE-2/3/4)
is now matched as "{1,2} to cover both serializations.
Verified against the session's AppWorkload.log via validate-ime-patterns:
ENFORCEMENT-SUCCESS 12 (was 0), ERROR-REPORT 14 (was 0); no CRITICALs;
the 2026-08-17 repair set confirmed live (ESP-TRACK-STATUS 24,
ESP-APP-REGISTERED 12, CACHE-MISS 11 in the agent's own match log).
Also observed in 1.104, tracked separately (no pattern change possible):
[DO TEL] line removed entirely - the DO download-duration display feeds
on it; needs an agent-side replacement (DO collector enrichment).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MDM LOB Bootstrap-MSI chain (EnterpriseDesktopAppManagement CSP ->
msiexec -> runner -> bootstrap -> --install) carries a SYSTEM token with
SeTimeZonePrivilege and SeSystemEnvironmentPrivilege REMOVED. WMI
Win32_Process.Create duplicates the caller token onto the runtime, so
every MSI-delivered agent ran degraded for its whole life: tzutil exit 5
(ACCESS_DENIED) on timezone auto-set and privilege_denied on SEC-001
firmware reads (observed 2026-08-17, sessions a11102f4/0fe2c562/3ae7528b
vs. platform-script sessions 7b072755/c4c1c2d5 on the same binary).
Install mode now probes its own token (TokenPrivilegeProbe, fail-soft to
"held"): with a full token the proven WMI-first chain is unchanged; on a
stripped token the order flips to schtasks /Run first - the Task
Scheduler service starts the task with a fresh full SYSTEM token - with
a 10s process-verification poll (schtasks only reports "queued", the
2026-04-30 silent-failure lesson), WMI as degraded-but-alive fallback,
BootTrigger defer as the final net (full token there anyway).
Also: MSI runner log encoding - PS 5.1 *>> redirection wrote UTF-16LE
into the Add-Content (ANSI) msi-bootstrap.log, garbling every bootstrap
line; script output now routes through the same Add-Content pipeline.
Runner fix needs an MSI rebuild (wix build) to ship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows were plain <tr onClick> so the browser offered no context-menu entry.
Each non-interactive cell now contains an invisible absolutely-positioned
Link (prefetch off) giving a real href for right-click/middle/ctrl+click;
visuals and SPA click behavior are unchanged. Cells with own buttons or
title tooltips (status, tenantId, actions) keep the row-click fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUT rules/ime-log-patterns/{patternId} sat on the TenantAdminOrGA tier
while the handler only checks ?global=true and writes the GLOBAL pattern
with no GA re-gate - any customer tenant admin could mutate platform-wide
patterns via direct API call. The UI gate (isGlobalAdmin && globalAdminMode)
was cosmetic only.
Moved the entry to GlobalAdminOnly next to the reseed route and added a
regression test pinning both IME pattern write routes to that tier.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Audit vs decompiled IME 1.97.107: ~55/79 patterns verified verbatim, but
several were broken or dead against the current V3 pipeline.
Fixes:
- IME-ESP-TRACK-STATUS: drop trailing anchor (line grew ' with name: <n>.')
- HS-COMPLIANCE: tolerate both 'remdiation' typo and corrected wording;
new HS-COMPLIANCE-POST for the reworded post-detection line
- IME-DETECTION-RESULT: capture DetectedDifferentVersion (ordered first)
- IME-ENFORCEMENT-SUCCESS: unpin OldValue (Error/PendingReboot/null paths)
- IME-ERROR-REPORT: allow unquoted null OldValue on first write
- Disable 11 dead patterns (emitters removed with IME's old V1/V2 pipeline;
IME-DETECTED-OLD additionally collided with IME-SKIP-INSTALL-DETECTED)
New signals (existing actions, tailed files only):
- IME-ESP-APP-REGISTERED + IME-CACHE-MISS as app-name sources (replacing
dead IME-UPDATE-NAME-1/2/3)
- IME-WINGET-ERROR (WinGet failures were invisible)
- IME-EXITCODE-MAPPED-FAILED (normal mapped-exit-code failure path)
- IME-STOP-SIGNAL (service stop != OS shutdown)
- IME-RESTART-STOP-PROCESSING (reboot-gated processing abort)
Backend seed diff now also compares Enabled + Parameters - enable/disable
flips and parameter changes previously never propagated to the table.
All exact wordings verified against decompiled source; regex smoke-tested
via validate-ime-patterns.py (engine=regex, 75/75 compiled, all new
patterns fire on synthetic lines with the exact IME format strings).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes from the a11102f4 diag analysis:
- enrollment_type_detected re-implemented the old CloudAssigned*-only
detection and kept reporting "Autopilot v1 (Classic ESP)" on WDP devices
while the session row already said v2. DeviceInfoCollector now uses
EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule() - the same
detector as registration - and the event names the rule that fired.
- Tenant-configured .evtx paths never made it into the diagnostics ZIP:
active event-log channels hold their file exclusively locked. The
package service now exports the channel via wevtutil epl (channel name
derived from the %4-encoded file name) and streams the temp export.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WDP batch order is fixed (User -> SLDM/MDM apps -> Scripts -> Win32Apps),
so a WDP-policy script can never observe the app phase live, and a hard
Batch-1 failure leaves zero visibility. LOB MSIs install via OMA-DM during
the first sync - before the SLDM batch - making them the earliest delivery
channel. The MSI carries no logic: it installs one fail-soft runner script
that downloads and executes the CURRENT server-hosted
Install-AutopilotMonitor.ps1, so all real bootstrap logic stays updatable
without re-shipping the MSI. Skips via the Deployed regkey when another
channel won the race. Build: wix build Package.wxs -arch x64 (WiX 5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field-verified on a live WDP enrollment (session 7b072755): WDP devices
carry NO Autopilot profile, so both CloudAssigned* v2 signals are
structurally absent and every WDP session was classified v1. The reliable
marker is the Device Preparation orchestration tree
AutopilotSettings\DevicePreparation\BootstrapperAgent, whose
ExecutionContext JSON carries the WDP policyId - present on WDP, absent on
classic v1 (verified there too; classic only has ESP category VALUES named
DevicePreparation.* on the parent key, hence a subkey probe).
CloudAssigned* checks stay as defence in depth. Profile seeding and the
downgrade guards from earlier today take over once registration carries v2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field-verified against the portal list: Intune normalizes corporate
identifiers on upload - uppercased, all non-alphanumerics stripped per
component ("Microsoft Corporation,Virtual Machine,7801-5131-..." is stored
as "MICROSOFTCORPORATION,VIRTUALMACHINE,78015131...") and the device
matched + enrolled against that normalized entry. Our exact-match against
the raw device values (and the contains() filter using the dashed serial)
could therefore never hit.
Both sides now normalize before comparison, and the contains() narrowing
uses the normalized serial. Raw-form entries created directly via Graph
still match. The not-found message shows raw AND normalized form so
rejection logs line up with the portal list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The searchExistingIdentities ACTION requires
DeviceManagementServiceConfig.ReadWrite.All, which the app deliberately does
not hold - the feature was broken in every tenant. Listing the same data via
GET works with Read.All, which the app already has for
windowsAutopilotDeviceIdentities. No new permission, no customer re-consent.
- contains(importedDeviceIdentifier,serial) filter for server-side
narrowing (mirrors AutopilotDeviceValidator), exact case-insensitive
match client-side on manufacturerModelSerial identities only
- 400 filter rejection falls back to an unfiltered paged scan
(60 x 1000 covers the portal maximum of 10 CSVs x 5000 rows);
page-cap overrun is transient, never a cached false negative
- Graph 401/403 stays a definitive, negative-cached failure with an
actionable message (now naming Read.All)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two changes from the WDP live-test follow-up:
- Remove the serialNumber-type probe again (decision: only the documented
Windows manufacturerModelSerial identifier authorizes; a serial-type entry
is an Intune-side admin misconfiguration and must not count).
- Classify Graph 401/403 on searchExistingIdentities as DEFINITIVE with an
actionable message naming the missing permission
(DeviceManagementServiceConfig.ReadWrite.All / admin consent) and
negative-cache it. Field case: the missing consent was classified
transient and trapped the agent in an endless 503 Retry-After loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Docs research showed the serial-only corporate identifier type is NOT the
documented WDP path (Windows only supports manufacturer,model,serial; Intune
ignores serial-type for Windows ownership). The dual probe stays because the
serial type is the only one the portal offers for MANUAL entry, so admins
land there by mistake and an uploaded serial still expresses authorization
intent. Comments and test docs now state that rationale instead of the
incorrect "WDP guidance uses serial type" claim. No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First live WDP enrollment 403d at device validation. Root causes:
- CorporateIdentifierValidator only searched the manufacturerModelSerial
identifier type, but the Device Preparation guidance has admins upload
SERIAL-type corporate identifiers - those never matched. The Graph
searchExistingIdentities call now probes both types in one round-trip.
- SecurityValidator overwrote deviceValidationError per validator, so the
403 Details only showed the LAST failure (CloudPc) and hid the corporate
identifier miss. Errors now aggregate in chain order and the transient
flag is sticky-OR (any transient failure yields 503 Retry-After instead
of a definitive 403).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SessionSweepFunction (cron 0 30 * * * *, offset from the 2h Maintenance
timer at minute 0) runs only MarkStalledSessionsAsTimedOutAsync, halving the
sweep-induced classification latency; thresholds unchanged. The sweep now
returns a SessionSweepResult (counts + catastrophic-failure message) so the
new timer emits SessionSweepCompleted heartbeats / SessionSweepFailed errors
(dual-registered in the ops-alert web catalog). No lease: the sweep is
idempotent, overlap with the 2h chain is a no-op.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the 3b7ba31f (succeeded) vs gardner/esa (parked) hybrid session comparison:
explanation now covers the cached-credentials/pre-logon-VPN variant and why the
Hello wizard never appearing is downstream of the missing PRT; new remediation
block for VPN machine tunnels, cloud Kerberos trust, explicit Hello disable,
update deferral and pre-provisioning. Links the new docs article.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two low-risk fixes ahead of the first live Device Preparation enrollment:
- StoreSessionAsync: EnrollmentType is now sticky-v2 across re-registration
(same contract as the sticky-true booleans) so a degraded AutopilotSettings
read after a reboot can no longer flip an observed v2 session back to v1.
- EnrollmentScenarioProfileUpdater: ImeUserSessionCompleted,
SelfDeployingDeadlineConfirmed and WhiteGloveSealingConfirmed no longer
overwrite an established DevicePreparation profile (seeded at Medium from
the registry-deterministic v2 fact).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Telemetry follow-ups from the 7d tool-quality review:
- tool_call log lines now carry a capped errorMessage (thrown message or
leading toolError text) so error drilldowns name the failure class
instead of guessing from args; absent on success.
- get_rule_stats omits per-rule daily trend rows by default (they drove
50% of calls over the response cap at platform scope); new
includeTrends arg opts back in, trendsNote marks the omission.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The presence page header was a non-wrapping `flex justify-between`, so on a
phone viewport the title column was compressed to a few characters wide and
"Active Users" plus its description broke one or two words per line next to
the Window/Refresh controls.
Stack the title and controls vertically below the `sm` breakpoint and keep
the existing side-by-side row from `sm` up. The controls also wrap among
themselves on very narrow screens.
The user table had the same problem: its card used `overflow-hidden`, which
clipped the Tenant and Last seen columns off-screen with no way to reach
them. Wrap the table in the repo's usual `overflow-x-auto` container so it
scrolls inside its card, keep cells on one line, and trim cell padding to
`px-4` on mobile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwjVj2PvpVRk7QUjQf3i8C
A single corrupt session average (request spanning sleep/hibernate counts
wall-clock hours) inflated the request-weighted location mean by orders of
magnitude (Amersfoort: 73286 ms from one 1.38M-ms session). Add
MedianApiLatencyMs per location + global (upper median over per-session
averages, duration convention), base ApiLatencyVsGlobalPct on medians, and
switch web display/sort to the median with the weighted avg in the tooltip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test-only fetch stub matched the Entra authority via substring
(includes), which CodeQL flags as incomplete URL sanitization. Parse the
URL and compare the hostname exactly instead — also strictly more precise
for what the stub intends (intercept only the Entra host).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useProgressSearch calls the server-side lookup; the client-side filter
over the tenant list is gone with the list itself.
- useProgressEvents passes the serial on every events read and refreshes
the session summary via the lookup (guarded by sessionId so a newer
re-enrollment of the same serial cannot swap the selected session).
- SignalRContext.joinGroup takes {serialNumber, onDenied}; the serial is
remembered per group and re-presented on the automatic post-reconnect
rejoin — without that, a roleless viewer's live updates would die on
the first network blip. A refused join is now visible (console.warn +
progress-page notification) instead of a silently frozen page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Security finding: GET progress/sessions shipped the tenant's latest 100
sessions to any authenticated (roleless) user with the serial filter
applied only in the browser; events and SignalR session-group joins were
open to any known sessionId.
Model now: knowing the device's serial number is the authorization proof.
- progress/sessions/lookup (AuthenticatedUserWithRole + QueryParam)
resolves at most ONE session server-side: roleless callers need the
exact serial/device name, members/GA keep substring search. The
tenant-wide list route is gone (catalog fail-closes it).
- progress events require ?serial= re-proof on every read; missing
session and serial mismatch answer the same 404 (no existence oracle).
- Session-group joins by roleless same-tenant callers require the serial
(SerialKnowledgeProof, shared with the events route); member/GA/
delegated joins are unchanged. MemberRead on progress routes stays
deliberately rejected (regression history c4dabeee).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- /oauth/token: AbortSignal.timeout(15s) on the outbound Entra fetch;
TimeoutError maps to 504 token_exchange_timeout (502 stays for hard
failures). Undici defaults previously allowed ~300s per stalled request.
- Boot guards (fail-fast, matching config.ts posture): AUTOPILOT_ENTRA_AUTHORITY
must be https (sovereign clouds allowed, host not pinned); MCP_PUBLIC_URL pin
must be https (http tolerated for loopback dev only), trailing slashes
stripped; production log hint when AUTOPILOT_ENTRA_CLIENT_SECRET is unset
(public-client vs AADSTS7000218 misconfig).
- State one-time-use: documented as accepted residual risk (a nonce store would
reintroduce the shared state the stateless multi-replica design removed;
impact bounded by single-use PKCE-bound Entra codes + callback re-validation).
- Tests: oauth-boot-guards.test.ts (validators) + token-timeout route tests
with Entra-host-scoped fetch stub. Suite: 559 passed, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full verify pass over all 54 built-in rules (validate_rule sweep, 30d/7d
get_rule_stats, test_analyze_rule spot checks). Fixes:
- ANALYZE-APP-007 v1.1.0: declare enrollment_failed as a non-required
condition — the exists-factor referenced an undeclared signal and could
never apply.
- ANALYZE-APP-014 v1.1.0: add non-required event_data conditions for
espCategory/espErrorCode/topCandidatePackage/candidateCount/confidence
so all explanation {{tokens}} resolve instead of rendering literally.
- ANALYZE-ENRL-002 v1.2.0: capture session_timeout.timeoutHours so
{{timeoutHours}} interpolates (verified via dry-run: renders "5").
- RuleEngine TryFlatLookup/ResolveDotPath: flatten IEnumerable data
values to a comma-joined string — List fields (e.g. app_tracking_summary
.pendingNames) stringified to "System.Collections.Generic.List`1[…]",
breaking contains-matching and {{pendingNames}} interpolation.
- GetRuleStatsAsync: raise the 500-row cap to 10000 and log when hit.
The scan walks dates ascending, so the old cap silently dropped the
NEWEST dates of a 30d window — newly added rules (e.g. ANALYZE-SEC-008)
were entirely missing from global stats.
- MCP validate_rule lint: accept event_data_array itemField as a {{token}}
resolver — {{identity}} in ANALYZE-SEC-005/006 was a false positive.
Backend 4157 tests green, MCP 548 green + tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The evaluateOn feature was API/MCP-only — portal users could not see or set
evaluation triggers. The rule editor gains an Evaluation Triggers section
(enrollment end / WhiteGlove seal checkboxes + on_event type list) with
client-side grammar + hard-block validation sourced from the baked guardrails
mirror (combine.js now emits BLOCKED_INTERIM_TRIGGER_EVENT_TYPES for the web);
rule cards show an 'Interim: …' badge for interim-enabled rules (incl. the
built-in ANALYZE-ID-004). Create/edit payloads round-trip evaluateOn; the
template-copy path already deep-clones it server-side.
Suites: web 762 + tsc + lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A warning does not stop an end user — telemetry-cadence types (performance_snapshot,
agent_metrics_snapshot, download_progress, network_state_change/connectivity_check,
log_entry, agent_trace, stall_probe_check) would turn every ingest batch into an
analyze run. Now enforced in three layers:
- AnalyzeRuleTriggers.BlockedOnEventTypes (code-only enforcement): CRUD write is
rejected with an explicit message, and OnEventTypes() drops blocked types at
runtime so even a persisted row (pre-validation data, direct table write) is
inert for the trigger registry and the engine alike.
- rules/guardrails.json gains blockedInterimTriggerEventTypes as the display/
pre-flight mirror (baked into the MCP RULE_GUARDRAILS via combine.js); a
parity test pins mirror and code set against drift — same pattern as the
gather hard blocks.
- MCP validate_rule: the high-frequency finding is now an ERROR sourced from the
baked mirror, not a hardcoded warning.
Suites: Backend 4155, MCP 548.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OKF concept doc (status SPEC): opt-in interim analyze runs via evaluateOn
(enrollment_end default, whiteglove_sealed, on_event:<type>), RuleResults
update-semantics replacing the permanent dedupe freeze, KO/stat suppression
on interim runs, notification marker decoupled from row existence, and the
validate_rule lints that keep interim rules monotonic. Closes the design for
both the sits-d WhiteGlove notification gap and the esa never-terminal
stuck-session gap with one mechanism.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The /settings layout now admits any own-tenant role or platform scope via
hasOwnTenantOrPlatformRole — the same set the sidebar's Configuration
visibility uses, closing the "sidebar shows it, guard bounces it" mismatch
the nav matrix test had ratcheted. Read-only stays enforced by the data
layer: MemberRead serves non-admins redacted config, canEditConfig is
false, and every write path (PUT + field PATCH) is TenantAdminOrGA.
This also realigns the code with the customer docs, which already
documented Operators/Viewers as read-only settings viewers.
The guard-consistency ratchet is now empty and may only stay empty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P5.x-A: Constants.SignalRMessages catalog (13 names) + SignalRGroupHelper.HubName;
all backend send sites and web subscriptions typed against the shared-manifest
union (SignalRContext.on/off) — unknown names are tsc errors. Dead eventStream
session-payload handling removed from diagnosis page.
P5.x-B: RowKeyCodec as the single inverted-tick encoder (persisted widths pinned,
UserActivity D20 legacy); DeletionRowDump.Table on tombstone rows with
DeletionTombstoneTables.Resolve replacing the duplicated Contains('_') heuristic
(legacy fallback retained for pre-field manifests).
P7.1: PATCH config/{tenantId}/fields flipped to TenantAdminOrGA (phase 2);
Settings sections save via per-section diff PATCH from sectionFieldMap.ts —
the ~39-field stale round-trip vector is gone; legacy webhook clearing now only
on the notifications save; agent-settings reset no longer discards contact edits.
P7.2/7.3: useGlobalAdminScope is a projection over useAggregatedAdminScope
(resolveConcreteScopeView, test-pinned); scopedApi builders own the tenant/global
endpoint-pair routing for all 11 former hand-rolled call sites; scopedFetch adds
the shared JSON fetch layer.
P7.4: navVisibility extraction + roles x nav-config matrix and nav<->route-guard
consistency test with a ratchet for the two known pre-existing mismatches
(Operator and GlobalReader vs /settings — user decision pending).
P7.5: web build stamp public/version.json (prebuild, GITHUB_SHA), SWA no-store
route, deploy-web verify poll + versions/web.json manifest, Portal Build card.
P7.6: OkAsyncBaselineGuardTests freezes the 44 anonymous-response sites
(shrink-only ratchet).
Verified: dotnet build 0W/0E, Functions.Tests 4123, DecisionCore 519, V2.Core
2206, SummaryDialog 44; tsc 0, ESLint 0, vitest 762/762; mutation proofs for all
new drift nets. Docs: docs/drift-guards.md + versioning.md web row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live verification after the P5/P6 deploy caught a request storm: the dashboard
auto-sweep effect refires whenever the loading flags toggle, and a q= sweep
whose matches were all already loaded changes nothing the effect watches — so
the same /api/search/sessions request looped indefinitely (~6 req/s observed).
The old loadAll() sweep terminated the effect via hasMore=false; searchAll()
deliberately leaves pagination state alone and therefore needs its own latch.
searchAll now runs at most once per trimmed query (latched before the first
await, also on failure); refetch/refetchWith/global-mode-toggle clear the latch
so a new backend scope may search again, and the explicit "Search all sessions"
button bypasses it via force. Ref declared with the sibling refs BEFORE the
mutating callbacks — the React compiler only accepts ref mutations when the
useRef declaration precedes the closure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four-tier model: Member (Progress Portal only) < Viewer (sees everything,
changes nothing) < Operator < Admin. Backend policies already treated
Viewer correctly (MemberRead admits it, every write tier excludes it) —
this change makes the tier reachable and safe:
- role assignment now validates against Constants.TenantRoles
(case-insensitive, canonicalized, 400 on unknown) — previously ANY string
was persisted; also fixes the case-sensitive last-admin guard
- Viewer is assignable in all four role dropdowns; About Roles describes
the four tiers
- shared hasTenantReadScope() replaces four hand-rolled predicates
(AuthGate, dashboard x2, useDashboardSessions); Viewers land on the
dashboard and the full navbar/sidebar ("adminOrOperator" visibility
renamed to "tenantMember"); Settings admits Viewer read-only
- "Analyze now" stays Admin/GA/Operator (recompute trigger, not a view)
- deleted dead IsTenantMemberAsync (meant Admin-or-Operator — the inverse
of RequestContext.IsTenantMemberRole)
- 16 endpoint tests for role validation, 15 tests for the scope helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A snapshot written by a different state-schema generation (e.g. the
pre-self-update binary) is no longer trusted: it is quarantined like a
corrupt cache and the state is rebuilt from the authoritative SignalLog,
whose signals carry their own KindSchemaVersion — replaying them through
the current reducer is the defined upgrade path. Unreadable log escalates
to the existing total-loss quarantine. Closes the silent-Newtonsoft-default
window where a reshaped field could cause mid-session amnesia across a
self-update. Two adversarial recovery tests pin both paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P3 — shared manifest infrastructure: SharedManifestParityTests reflects the
wire-relevant catalogs out of AutopilotMonitor.Shared into a committed JSON
manifest (regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test); a web codegen
lifts it into literal types; sharedManifestChecks.ts enforces
AdminConfiguration field parity at COMPILE time and 12 vitest tests pin the
value catalogs (sources/operators, lanes/verdicts, severities incl. the
missing Trace=-1, phases both directions, terminal statuses, event-type
typo guard). KnownSources/KnownOperators made internal for the export.
P4 — agent safety nets: DecisionState builder/serializer completeness
(4-site sync tripwire), signal-kind fixture coverage with a shrink-only debt
set (found and fixed realmjoin-timeout-v1.json, which never deserialized),
RemoteConfigMerger completeness tripwire, DefaultComponentFactory ordering
invariants as real construction tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GET now computes writableLanes with the SAME function the PUT re-gates
with (IsLaneWritableByCaller, own-tenant binding included) and the card
renders exactly that list — the hand-mirrored TS matrix is deleted. Fixes a
latent UX authz bug (a GA without an own-tenant admin role was offered the
tenantadmin lane and would have hit a 403). Older backends without the field
fail closed to read-only, so web can ship independently; save affordances
appear once the backend is deployed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- adminConfig.ts: drop retired V1 latestAgent*/customSettings ghost fields,
add the 8 backend fields the interface was missing
- ops alerts: register the Sla category (BreachNotification/ConsecutiveFailures/
EvaluationCompleted) + OrphanEventsCleaned so operators can route them
- analyze rules: expose app_install_duration in the builder SOURCES;
fix stale XML docs (event_data_array, count_per_group_gte/in/not_in)
- UploadIntervalSeconds: collapse 5 diverged defaults (10 vs 30) onto
Constants.DefaultUploadIntervalSeconds = 10 (the served value)
- combine-rules workflow: commit ALL three combine.js outputs and include
rules/guardrails.json in the path filters
- api.ts apps sessions builders: move trailing optional strings into an
options object (adjacent optional strings swallowed a forgotten tenantId)
- SummaryDialog: canonical phase display names ("Apps (Device)")
- mcp-server.bicep: fix pre-EU-cutover hostname in deploy docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The confidence bar and the two fleet-context notes on an analysis finding
shared a non-wrapping flex row, so on phones they squeezed into
word-per-line columns — the row now wraps, each note taking its own line.
Also visible in the same screenshot: "critical path NaNh NaNm" in the
ESP-blocking apps block. The backend omits null fields (WhenWritingNull),
so espAppsOccupancySeconds arrives as undefined and the strict !== null
guard let it through to formatDuration. Loose != null check now hides the
label when the value is absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
versionBreakdown on the app analytics responses now carries measuredInstalls,
medianDurationSeconds and p95DurationSeconds per version (measured population:
succeeded, non-skip, plausible duration, no AppId collision). A new pure
AppVersionRegressionRadar detects "median install duration rose from X to Y min
after version Z": versions ordered by first-seen install time (never string
sort), fires at median lift >=2x AND >=300s absolute with >=10 measured
installs on both sides.
Episodes follow the rule-radar pattern in a fourth tracker keyspace
appversionregression|{app}|{version} (409-dedup, refresh keeps FirstNotifiedAt,
re-arm on drain / median back under 1.5x, 30d retention sweep re-arms): one
tenant bell app_version_duration_regression (Admin audience, appDetailUrl href)
plus one AppVersionDurationRegression ops event per episode, dual-registered in
the web OPS_EVENT_TYPES catalog. Runs in the maintenance timer after the rule
radar over one cross-tenant 35d install-summary read; kill switch
AppVersionRegressionRadarDisabled (fail-open). Apps detail page shows an amber
regression banner and a median-duration-by-version chart; OKF concept doc added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each fired analyze rule on the session detail page now reads "Fired in N
enrollment(s) in the last 14 days" (summed client-side from the existing
rule-stats trend — enrollments, never devices; RuleStats carries no serial)
with a "View sessions" deep link. The dashboard accepts ?ruleId=, fetches
the rule's hit-session ids once from the new GET /api/metrics/rule-hit-sessions
endpoint (MemberRead + QueryParam scoping, capped 2000 with a truncated flag)
and filters the loaded list client-side behind a clearable chip.
Side fix: the section's stats fetch now follows the session's tenant for
GA/delegated viewers (global route + tenantId) instead of showing the
viewer's own-tenant numbers next to a foreign session. Dashboard links in
fleet/fleet-health now go through the new canonical dashboardUrl() builder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A CVE published against an already-referenced package must not block an
unrelated hotfix deploy; advisories are handled by bumping the package.
WarningsNotAsErrors NU1901-NU1904 - everything else stays fatal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repo-wide Directory.Build.props: TreatWarningsAsErrors +
MSBuildTreatWarningsAsErrors. Applies to compiler, MSBuild and NuGet
restore warnings including audit advisories (NU1901-NU1904) - a newly
published CVE fails the build until addressed (fail-closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CmTraceLogParser: annotate TryParseLine (string? line, [NotNullWhen(true)]
out entry), init string properties (CS8618/CS8625/CS8604)
- Shared: internal NotNullWhen polyfill for netstandard2.0; multi-target
netstandard2.0;net10.0 so net10 consumers use the BCL attribute and the
polyfill cannot collide via InternalsVisibleTo (CS0433)
- AgentEndpointMigrationRules.TryNormalizeTarget: now carries [NotNullWhen(true)]
- Functions.csproj: drop DataProtection/Cryptography.Xml security pins made
redundant by .NET 10 framework-package pruning (NU1510)
- Functions.Tests.csproj: add Microsoft.AspNetCore.App FrameworkReference so
pruning applies there too and the pins stay unnecessary (NU1903/NU1904)
- Agent tests: nullable test-helper param, Assert.NotNull before dereference
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/annotations now carries the standard cross-tenant scope (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 never receive the platform-internal
globaladmin lane (endpoint-test-pinned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 99-warning legacy backlog from the React 19 / eslint-plugin-react-hooks
v6 upgrade is fully burned down — CI now fails on any new warning. The
compiler rules stay demoted to "warn" so local dev output remains readable;
the eslint config comments now point to the established fix patterns
instead of the retired ratchet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AuthContext: delete the SSR isLoading effect — effects never run on the
server, the guard was unreachable dead code.
- TenantContext: mirror user.tenantId via adjust-during-render; the value
is still retained while auth resolves and cleared on settled sign-out.
- Global/Tenant notification contexts: scope-masked empty list derived in
the provider value instead of effect-clearing; loading flag moved into
the canonical async wrapper.
- New shared hooks/useMediaQuery (useSyncExternalStore, configurable
server snapshot) — consumed by GlobalSidebar (isDesktop) and
McpTerminalDemo (prefers-reduced-motion, finished transcript derived).
- GlobalSidebar: route-mode active section derived from pathname;
active-group and pathname auto-expansion as adjust-during-render, state
now holds user toggles only.
- ThemeContext: theme read via useSyncExternalStore backed by
lib/themeStore (manual preference beats OS preference in the snapshot;
same-tab writes notify through the store's listener list).
- TipOfTheDay: random tip via useSyncExternalStore with module-memoized
pick (build-time Math.random would freeze one tip and mismatch on
hydration); re-rolls per mount via cleanup.
- Navbar: previewMode block removed entirely — nothing in the codebase
consumes the flag anymore, it only persisted itself.
- activation page: ?demo flag via useSyncExternalStore, tri-state null
sentinel dropped.
Lint is at zero react-hooks warnings repo-wide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structural fixes for the set-state-in-effect sites that were genuine
state synchronization, using the React-documented patterns:
- adjust-during-render (compare-prev) for reset-on-dep-change: dashboard
page reset + tenant-filter clear, InstallsTab/MappedSoftwareTab page
resets, TenantManagementSection URL seeding + page reset,
OpsAlertRulesSection draft re-seeding, SessionReportsSection deep-link
auto-open, ReportSessionModal result reset.
- clamp/derive at read: dashboard effectivePage (also removes the
documented handleNextPage race), backups detail effectiveTableName,
useBlockDevice scope-masked set, InstallProgress and
useProgressDerivedData live timers (startedAt-tagged tick state),
sessions timeBreakdown read guard.
- key-based reset: SessionReportsSection note editor, DeleteConfirmModal
content remount.
- inverted state: sessions page stores collapsedPhases so new phases are
expanded by default with no sync effect; hash deep-link expandAll moved
into its timer callback.
- tagged result state: useScriptDisplayNames stores {key, map} and serves
the empty map on key mismatch — same staleness guarantee, no
effect-phase clearing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useProgressDerivedData: hoist the currentDownload computation into a
module-level pure helper — the in-loop Map mutation and early return
kept the compiler from memoizing the body inline.
- DeviceDetailsCard: derive bootTime from events inside the uptime memo
(deps [events]) — the outer const is a live alias into event data that
the compiler's alias analysis treats as maybe-mutated.
- useSessionEvents: route the queued follow-up re-invocation through a
ref — a memoized callback that calls itself directly cannot be
memoized. The now-traceable mount effect gets the standard wrapper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Convert every useEffect that directly invokes a state-setting fetch
callback to the canonical inner-async shape (const run = async () => {...};
void run();) — the react-hooks/set-state-in-effect rule traces direct
calls but accepts effect-scoped async functions. Semantic no-op: an async
body runs synchronously until its first await, so execution order is
byte-identical. Leading loading/error/data resets that are part of the
fetch flow move to the top of run in unchanged order.
52 files, ~60 sites. Lint 99 -> 36 warnings; remaining sites are
reset/derive effects and sensitive context/sidebar refactors (next
batches).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The annotations overview pushed the ESLint warning count to 100, one over
the frozen --max-warnings 99 cap, failing CI since 66d25f57's run. The
react-hooks/set-state-in-effect rule flags any direct effect call into a
state-setting useCallback, even with every setState behind an await.
Restructure instead of suppressing: loading/error begin-state moves from
fetchPage into the interaction sites (filter selects, load-more; mount is
covered by the initial state), and the effect invokes fetchPage through an
inner async wrapper so no setState is synchronously reachable. Lint is
back to exactly 99.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 404 (backend without the route yet, deploy skew) now renders the
regular empty state instead of an error; genuine load failures show a
calm non-technical retry hint in amber instead of raw statusText in red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node 20 actions are deprecated on GitHub runners; actions/cache@v4 was
the last remaining action targeting it (MCP server tests job warning).
v6 runs on node24 like the rest of the workflow actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Autopilot Monitor Team" read like a vendor-to-customer message channel.
The lane only ever renders for platform-scope users, so it now says
"Internal (Autopilot Monitor)" and carries a "never visible to the
tenant" badge on the session card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Windows 365 Cloud PCs a missing Autopilot profile (ProfileAvailable=0)
is the expected state - Cloud PCs are provisioned by the Windows 365
service, not by Autopilot - so the warning shown for physical devices is
misleading there.
- Agent: emit autopilot_profile_missing as Info with a Cloud-PC-specific
message when CloudPcDetector matches; payload now carries isCloudPc.
Evidence collection (ZTD verdict, registry, endpoint probe) unchanged.
- Web: DeviceDetailsCard renders a neutral info note instead of the amber
warning for Cloud PC sessions (session.isCloudPc, sticky, covers
existing sessions; or the new event marker).
- MCP: resource-catalog documents the new isCloudPc data field.
- Docs: Cloud PC exception noted in agent/autopilot-ztd-diagnostics.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Next 16.3's new @next/next/no-location-assign-relative-destination rule
flags relative URLs assigned to window.location, pushing the lint warning
count past the CI cap of 99.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same wording as the customer docs; plain browser tooltip, no cursor
change. Applied to the verdict buttons and the read-only pill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubmitReportAsync adds annotations.json (all lanes) to the package and
annotationCount to report-metadata.json. All lanes are safe: report
blobs are readable only through the GA-only session-reports routes.
Fail-soft like the tenant-context snapshot; the copy outlives the
session=s retention/delete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verdict comes at the end of a diagnosis, so the card must not take
space from the details/analysis work above it. Clickable chevron header
per section convention; count + verdict pills stay visible collapsed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + 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; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex follow-ups to 1ebc2675:
- guardrails.json now mirrors blockedCommandPatterns + maxCommandLength and
carries C:\Windows\System32\config in blockedFilePrefixes; portal
(guardValidation.ts) and MCP (rule-validation.ts) enforce them in agent
order (before the unrestricted-mode early-return), so hard-blocked commands
no longer show "Allowed" in unrestricted tenants. GuardrailsParityTests
pins the JSON mirror to the C# constants.
- gather-rule schema: collectorType enum gains json/xml (agent + portal
supported both; MCP validate_rule falsely rejected them); MCP validates
json/xml targets with file semantics and rejects activePhases +
activeFromPhase set together (backend 400 parity); authoring guide updated.
- Author anti-spoof: tenant + global UpdateRule stamp the caller's JWT
display name before the service call, closing the PUT-upsert hole where a
non-existing ruleId stored the payload author; true updates still preserve
the original. GatherRulesFunctionAuthorEndpointTests drives the real
endpoints (first HttpRequestData-level test) proving body authors never win.
- AgentBootstrap.RetrySelfUpdateAfterEnrollment: internal + UpdateInvokerOverride
test seam (RequestGracefulShutdown idiom), LastVersionCheckSucceeded setter
internal; AgentBootstrapSelfUpdateRetryTests pin the guard, the relaxed
await_enrollment timeouts, and the swallow-everything exception contract
with save/restore of the static state.
Functions 3958, Agent-Core 2200, Web 670, MCP 543 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 13dd6723: a platform-403 (stopped/retired app, HTML error
page) now skips the DeviceNotRegistered/AuthCertificateRejected distress
report - it would be sent to the very endpoint that just answered with a
platform page, and its classification would be wrong. Counting and the
shutdown thresholds are unchanged, so a dead endpoint still drives the
agent to its soft shutdown instead of retrying forever.
The one-report-per-streak gate moves from "count == 1" to a streak-scoped
flag: an endpoint-unavailable failure no longer consumes the streak's
report slot, so a later genuine 403 from a live backend still dispatches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- WMI guard accepts SELECT <property-list> FROM <class> for any class already
allowed via SELECT * (strict data subset); same algorithm in agent, portal
validator, and MCP validate_rule (incl. trim fix); guardrails.json unchanged
- Portal refuses to enable custom rules with not-allowed targets: create lands
disabled, invalid edit force-disables, "Blocked on devices" badge, GitHub-issue
hint for allowlist requests; agent stays the enforcement boundary
- Rule Author is stamped from the creator's token (display name > UPN) at create
and preserved through every update, incl. cross-tenant GA edits
- bumpVersion keeps the segment count ("1.0.0" -> "1.1.0")
Customer feedback: SELECT BatteryStatus FROM Win32_Battery + on_change was
impossible because SELECT * re-emits on every charge-percent fluctuation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docs page and the admin UI both told customers to run
Grant-AutopilotMonitorAddOn.ps1, but the script was never published
anywhere -- it only exists in this private repo. Publish it on the
download alias next to the agent artifacts and reference it:
- build-agent.yml: upload the script on publish_as_stable runs
- admin UI: copied grant command now starts with an irm -OutFile
download line; prose links the script directly
- utils/config.ts: new AGENT_DOWNLOAD_URL registry constant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field case sits-d.cloud 2026-08-09: a pinned pre-cutover agent (2.0.1282)
hit the stopped legacy Function App. Azure answers with a platform-level
403 HTML page, which the agent reported as "device is not authorized" -
support chases a non-existent authorization problem, and the stranded
build never rescues itself because the startup self-update check runs
pre-enrollment where OOBE has no network.
Two fixes:
- BackendApiClient reads the 401/403 body: an HTML response cannot come
from our backend, so it is classified as endpoint stopped/retired/
proxied (BackendAuthException.EndpointUnavailable, page title in the
message) instead of a device-authorization verdict.
- AgentBootstrap retries the self-update check once after the MDM
certificate appears (proof of connectivity) when the startup check
never reached the manifest, with relaxed timeouts (10s check / 60s
download). An applied update restarts into a current binary BEFORE
session registration hits a retired endpoint. Marker files are also
written on this trigger so agent_version_check reflects the latest
verdict.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent logs are written UTF-8 without BOM; cp1252-guessing viewers
(Outlook web attachment preview) render em dashes as mojibake. Map
em/en dash, arrow, ellipsis, curly quotes and NBSP to ASCII at the
central sanitize step so every message is covered without touching
call sites. Genuine non-ASCII payload (paths, names) stays verbatim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vector-search tests tokenize with the real Xenova/all-MiniLM-L6-v2
model. transformers.js caches it under node_modules by default, which npm ci
recreates on every run — so each CI run re-downloaded the tokenizer/weights
from huggingface.co, and a rate-limited response (429) failed the whole MCP
job (run 31252869254; unrelated to the commit under test). Persist the model
via actions/cache and point HF_CACHE_DIR (already honored by
vector-search-provider.ts) at it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Classic UD fleets held at the Device-ESP timeout wall by one slow blocking
app showed ~100% Failed (tenant c9787ba2: 948/1000 esp_terminal_failure)
although the profile allows "Continue anyway" and users demonstrably reach
the desktop — the classic advisory defang requires AccountSetupEnteredUtc
and never applied there.
New operator-set tenant opt-in EnableEspContinueAnywayObservation
(ConfigVersion 36 -> 37, default off):
- DecisionCore: a Device-phase EspTerminalFailure with ContinueAnyway
allowed + opt-in observed (EspConfigDetected payload fact — kernel stays
config-free) + Classic/Unknown non-pre-prov scenario defangs into the
advisory rail with a 60-min AdvisoryCompletion window. Real end via the
DAD-validated real-user desktop + Hello gate (IME user-session gate is
structurally unavailable without the AccountSetup anchor): eager
completion on DesktopArrived, category recovery via the 4910a5a5 hook,
expiry un-defangs to the original esp_terminal_failure — every observed
session still gets a terminal verdict.
- Honest outcome: enrollment_complete under an unresolved advisory carries
espSoftFailure/completionSource (also on the classic post-AccountSetup
advisory path); backend stamps EspSoftFailure/CompletionSource on the
Sessions row (+ index mirror, terminal-tuple preservation); web renders
an amber "with issues" badge plus a session-detail banner. No new
terminal status — Succeeded + marker.
- Agent: remote-config field merged into AgentConfiguration; stamped into
both esp_config_detected emitters (orchestrator bootstrap via live
Func<bool> seam, DeviceInfoCollector via host/factory pass-through).
- Admin UI: GA-only toggle in the tenant management modal (next to
Bootstrap Token) + stored/runtime rows in the tenant config report;
GA-gated on both write paths (PUT silently reverts for tenant admins,
field patch rejects with 400).
Tests: 13 new ContinueAnywayObservationTests; DecisionCore 513, Agent V2
2162, Functions 3948, SummaryDialog 44, vitest 632, tsc clean.
Docs: docs/agent/continue-anyway-observation.md (OKF) + index/log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The enqueue fires in the very ingest batch that carries enrollment_failed /
enrollment_complete — by design the FIRST event of the agent's terminal
sequence. The worker's hot loop dequeued sub-second and evaluated rules
against a stream still missing the terminal-flush rows (stuck-promotion's
synthetic app_install_failed, app_tracking_summary, agent_shutting_down),
and the RuleResult dedup made the gap permanent. Session 53d1e9f6:
ANALYZE-ESP-004's optional app_install_failed condition never matched and
{{appName}} rendered as a literal placeholder (confidence 80, factor never
fired). A 30 s visibilityTimeout covers the flush including the agent's
late-event grace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Install-AutopilotMonitor.ps1 takes over the field-tested v2.3-dev.3 logic
unchanged (code-only diff vs dev: ScriptVersion + log file name); comments
condensed to customer-facing descriptions, dev changelog collapsed into one
v2.3 entry. Pester suite moved to Install-AutopilotMonitor.Tests.ps1 (neutral
test user names), dev script and dev suite removed. Test-ShouldBootstrapAgent
dry-run tester mirrors the Cloud PC trigger and the guard-4 uptime exemption.
Pester 29/29 green under PS 5.1 and pwsh 7; parse + pure-ASCII checks pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a tenant offboarding is queued or draining, the TenantConfiguration
tombstone (Disabled=true, 'Offboarding in progress') is the cascade's only
auth gate. A GA lifting Disabled mid-cascade re-opened the tenant against a
wipe that was still coming: data written afterwards was destroyed, and
sessions registered after enumeration survived as orphans while their event
partitions were wiped.
Three layers close it:
- PUT config/{tenantId} returns 409 while the row is the offboarding
tombstone — for all callers, GA included
- TenantConfigPatchService refuses patch AND revert against the tombstone
(a revert restores a pre-offboarding snapshot with Disabled=false — the
exact race)
- TenantOffboardingHandler re-reads the config before the destructive
post-drain phases and fail-closes with 'unoffboard_race' when the
tombstone is gone but the wipe has not run (last line of defense)
Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.
TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Header gradient, CTA button, and links move from the old blue
(#2563eb/#4f46e5) to the landing-page green palette (#1e8a4c/#33b161).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OG image was an unreadable shrunken hero at card size; replace it
with a centered mark-first design (big green shape, large wordmark,
single tagline). Safari cannot use SVG for apple-touch-icon and fell
back to a letter tile — point the icons config at the file-convention
apple-icon.png, now the green mark on white (180x180).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First W365 field run: the rejected Cloud PC appeared in the report as an
anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the
admin that the fix is the Cloud PC validator, not an Autopilot-registration
hunt. The agent's CloudPcDetector verdict (Windows365 key AND
CloudManagedDesktopExtension service) now rides the pre-auth distress channel:
- Agent: DistressReporter captures isCloudPc at construction (optional ctor
arg, test fakes stay source-compatible); BackendClientFactory feeds it from
CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the
1536-byte payload cap without a bump — comment updated)
- Backend: DistressReportEntry.IsCloudPc, table column + read mapping
(legacy rows -> false), structured log + AgentDistressReport AI event key;
GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR
semantics as the Sessions IsCloudPc merge) and the data-quality notice
names the new self-reported field. GA global/distress-reports serializes
the full entry and picks it up automatically.
- Web: Cloud PC badge in the Devices Not Registered table (same sky badge as
the session table) + conditional hint pointing at the Windows 365 Cloud PC
Validation toggle and the W365CloudPcValidation Graph add-on.
- Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains
the optional flag. Docs: third transport rail in
backend/cloudpc-device-validation.md + log entry.
Unverified like every distress field — triage context only, never an auth
input. Verified: web tsc clean, eslint 0 errors (file's single warning
pre-exists). .NET build/tests run in CI (no SDK in this sandbox).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs
Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.
- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
(default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
filter; exact-match guard; Graph 403 = definitive "grant missing", not
transient), SecurityValidator CN extraction + fallback stage + hard gate,
wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
Cloud-PC relax is active — Cloud PCs run headless for days before first
connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log
Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"
Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.
Two transport rails:
- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
(sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
-> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
filter end to end (SearchSessions + QueryRawSessions + pagination
fingerprint + all three in-memory predicates; MCP search_sessions /
query_raw_sessions params + summary projection; web type, Cloud PC pill,
SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
and annotates the scenario profile reason
cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
expectation that Device-ESP already ran headless at provisioning and the
session starts at Account Setup. Deliberately reason+observation only:
no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
classified (Classic), EspConfig keeps configured-FirstSync semantics.
Positive marker surfaces as cloud_pc_marker in the audit-trail census.
Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
reason branch from re-claiming the slot on repeat posts (would bump
EvidenceOrdinal every post). Additive-nullable observation - no state
schema bump (v4 convention), serializer roundtrip pinned.
Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).
Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
First W365 field test (2026-08-06) proved the assumed WMI signature wrong:
a real Cloud PC reports the generic Manufacturer='Microsoft Corporation' +
Model='Virtual Machine'; the 'Cloud PC Enterprise ...' string exists only in
Intune/Graph inventory. Probe on the live Cloud PC captured two local
markers instead: HKLM\SOFTWARE\Microsoft\Windows365 and the
CloudManagedDesktopExtension service (W365 management agent).
Get-CloudPcModel is replaced by Test-IsCloudPc requiring BOTH markers (each
alone has look-alikes: W365-Boot physical clients / legacy managed-desktop
agents). Raw identity and both marker states are always logged. Tests
updated to the marker model (26 tests, green under PS 5.1 and pwsh 7).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First W365 field test failed: the log said only "CloudPC: False" with no way
to tell whether Win32_ComputerSystem reported unexpected values or the query
failed. Get-CloudPcModel now logs the raw identity on every run and the
error message on failure. Detection pattern itself unchanged until the
probe from the real Cloud PC delivers evidence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a specific tenant is selected on an aggregated-capable page, an x
button next to the dropdown jumps straight back to the aggregate view
instead of requiring a dropdown roundtrip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The V2 agent emits whiteglove_complete twice (raw Shell-Core event 62407 +
engine timeline event, ~1s apart). The Pending status write was not
idempotent (terminal guard allowed Pending->Pending as a "transition") and
the ingest fallback additionally forced the transitioned flag, so tenants
received the Teams card twice (HSD report). Pending->Pending is now refused
in IsTerminalTransitionAllowed, and the ingest fallback distinguishes an
already-sealed session (duplicate, no notification) from a genuine write
failure.
Notification titles now use neutral traffic-light circles (green/yellow/red)
instead of rocket/checkmark/cross/siren emojis (customer feedback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap ad-hoc fetch closures in useCallback and complete effect deps
(SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext,
SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert
sla's initialLoad to a ref so the fresh-once flag can't retrigger the
fetch effect. Document the three intentional live-ref bumps in unmount
cleanups (cancellation counters) with justified disables.
Also fixes a real stale-closure bug: TenantConfigContext's save callback
serialized webhookNotifyOnHardwareRejection without depending on it, so
toggling only that setting saved the old value.
The 3 react-compiler preserve-manual-memoization infos remain (compiler
bailouts, not defects) - cap ratcheted to the exact current count of 99.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Load-all button reconstructed 'sweep running' locally via a
set-state-in-effect on loadingMore, adding a 116th react-hooks warning
over the 115 cap. The hook already knows loadMore vs loadAll apart, so
expose loadingAll from useDashboardSessions and drop the local state and
effect entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tenant rule IDs were free-form with only a point-in-time existence check,
so a customer could occupy a gap or retired ID like ANALYZE-SEC-002; a
later built-in with that ID would then silently shadow the tenant's rule
at merge time (global wins, tenant copy skipped) and their RuleStates
would start toggling our rule.
Enforcement: RuleIdPolicy (Shared) reserves (ANALYZE|GATHER)-<CAT>-<NUM>
case-insensitively, with the CUSTOM category as the sanctioned tenant
namespace (we never ship built-ins in a category named CUSTOM). Both
rule services reject reserved IDs on create AND on the custom-branch of
update (updates upsert, so PUT was a bypass); update endpoints now map
InvalidOperationException to 409 like create. Web forms mirror the check
inline; MCP validate_rule emits a warning finding and the authoring
guide now documents the reservation (guide templates moved to
GATHER-CUSTOM-101/ANALYZE-CUSTOM-101 — the old numeric examples would be
rejected). No existing prod custom rule violates the pattern (verified
against all 13 tenant-partition rows), so nothing needs grandfathering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rotation test swapped the process-wide static test signing key
mid-test; xUnit runs test classes in parallel, so any pagination test
encoding/decoding during that window failed with bad_signature instead
of its expected reject reason (seen as order-dependent one-off failures
in CI). New AsyncLocal-scoped override confines the swap to the
rotation test's own execution context; the process-wide TestSetup
default is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ANALYZE-SEC-002 was the sunset AutoLogon rule; BuiltInRulesTests pins
that the ID never returns (the CI failure on main). Reusing a retired
ID would also inherit any historical tenant state keyed to it. Rule
content is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fires on secureboot_status.uefiSecureBootEnabled=false; legacy BIOS
devices (registry key absent, agent sets note field) are skipped via
precondition. +20 confidence on physical devices. Validated with MCP
validate_rule (0 findings) and test_analyze_rule dry-runs: fired at 90
on a physical SecureBoot:False session, silent on a SecureBoot:True
session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Headers stack below sm, summary cards go 2-up on phones, top endpoints
show tool name with route as secondary line, and the top-users table is
replaced by stacked rows so requests/last-active are no longer cut off
on narrow screens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* updated commands
* feat(backend): logparser pattern test endpoint with agent-exact semantics
POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex
against pasted sample lines exactly the way the agent would on the device.
Motivated by a field incident: a customer verified a pattern with a PHP regex
tester and the .NET engine matched differently.
- CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent
(IME tracker, logparser collector, stall probe, IME matching mode) and
backend run the SAME parsing implementation. Pure mechanical move, agent
behavior unchanged (V2.Core suite 2147/2147 green).
- TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled,
1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group
"0" excluded / unsuccessful groups omitted, format cmtrace (default, match
against parsed message) vs text (raw line), per-line outcomes incl.
parse_failed/regex_timeout, and the agent's "set parameter format=text"
hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line.
- Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run).
- Tests pin the parity showcases: case-sensitivity, envelope invisibility in
cmtrace mode, optional-group omission, format hint, request validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): test_log_pattern tool + logparser lint and guide hardening
- test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a
logparser regex against pasted sample lines with the agent-exact .NET
semantics (case-sensitive, cmtrace/text mode). Description steers clients
away from JS/PHP/Python regex testing.
- validate_rule: logparser parameter lint (missing/non-compiling pattern,
unknown format value = silently cmtrace on the agent) + info pointing to
test_log_pattern; nextStep for valid logparser drafts routes there too.
- rule_authoring_guide: logparser section now states the matching semantics
(case-SENSITIVE, .NET, first match per line, cmtrace default) and two new
pitfalls (engine mismatch, plain-text log under cmtrace default).
- Catalog snapshot + handler/validator tests extended (MCP suite 532 green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.
Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.
Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second live miss, root cause found via end-to-end repro against the real SDK
stack: SDK 1.30 catches every McpError around the tool handler (Zod input
validation, unknown/disabled tool) and answers with a SUCCESSFUL JSON-RPC
response whose result is a CallToolResult { isError: true, text: "MCP error
<code>: ..." } — a JSON-RPC error envelope never exists for these. The sniffer
now recognizes both shapes and extracts the code from the text prefix; handler
soft errors (toolError) never carry that prefix, so they are not double-logged.
Repro verified: the sniffer logs tool_call_rejected against a live
express + StreamableHTTPServerTransport + zod-tool stack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live verification caught the miss: tool_call and search_zero_hit lines arrived,
tool_call_rejected did not. SDK 1.30 bridges the web-standard Response through
Hono's getRequestListener, which pipes ReadableStream bodies as Uint8Array
chunks via res.write followed by a BARE res.end() — the end(chunk) inspection
never saw the envelope. The sniffer (now attachToolCallRejectionSniffer in
telemetry.ts, unit-testable) wraps both write and end, reassembles up to 10 KB
and decodes string/Uint8Array chunks; tests pin the streaming path, the direct
end(string) path, the over-cap silence and the disabled no-wrap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
withToolTelemetry now receives the tool args and inspects the RESULT: handlers
never throw (toolError returns isError:true), so the previous isError flag was
effectively always false. Each MCP_TOOL_LOGGING line now carries soft-error
status, resultChars + overCap (vs. the anthropic/maxResultSizeChars hint),
a size-bounded args summary and the caller scope — the only telemetry that
covers backend-free tools (search_docs, validate_rule, get_resource).
New signals: search_zero_hit on the three search tools (unmet-demand signal)
and tool_call_rejected for JSON-RPC-level Zod/unknown-tool rejections, sniffed
at the /mcp route since the SDK answers those before any handler runs.
Bicep default toolLoggingEnabled=true (desired state; live flag via
az containerapp update). Docs: docs/mcp/tool-telemetry.md with KQL examples.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): dry-run endpoint for draft analyze rules (rule authoring)
POST /api/rules/analyze/dryrun evaluates a DRAFT analyze rule against one
session's events and returns a full diagnostic trace: per-condition
matched/evidence (incl. per-eventType occurrence counts), per-factor
confidence breakdown, verdict, and the would-be MatchedConditions map for
{{token}} interpolation preview. Persists nothing: no RuleResult row, no
session-status change, no notifications.
- RuleEngine.DryRun.cs: DryRunRuleAsync / DryRunEvaluate share the exact
evaluators (EvaluatePrecondition/EvaluateCondition/EvaluateConfidenceFactor)
with the production path but evaluate ALL conditions instead of breaking on
the first failed required one; parity with EvaluateRule is pinned by tests.
- DryRunAnalyzeRuleFunction: TenantAdminOrGlobalReader (read-in-effect POST),
GA/GlobalReader cross-tenant via session tenant resolution, 1 MB body cap,
structural draft validation (unknown source/operator, correlation without
joinField, invalid regex, non-evaluable confidenceFactor DSL) with
actionable error messages.
- EndpointAccessPolicyCatalog: register the new route.
- RuleEngineDryRunTests: trace semantics, production parity matrix,
side-effect freedom (VerifyNoOtherCalls), draft validation matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): rule-authoring resources — guide, schemas, guardrails
Deterministic authoring context for customer AIs, replacing chunked
search_docs retrieval for rule creation: get_resource (and the MCP-protocol
resources) now serve
- rule_authoring_guide: hand-written, complete authoring knowledge (collector
types, triggers, condition sources, operators, confidence model incl. the
exact factor-condition DSL shapes, {{token}} interpolation order, guardrail
matching semantics, pitfalls, validate->dry-run workflow)
- rule_schemas: the JSON Schemas (2020-12) for gather + analyze rules
- rule_guardrails: the on-device collection allowlists
Schemas + guardrails are generated into rule-authoring.generated.ts by a new
combine.js emitter (same pattern as the web guardrails mirror; rules/ stays
the single source of truth and CI guardrails-in-sync guards the file).
rule-authoring-drift.test.ts additionally pins generated == repo source,
guide enums == schema enums, and the get_resource size cap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): validate_rule + test_analyze_rule tools and author-rule prompt
Closes the authoring loop for customer AIs: draft -> validate -> dry-run
against a real session -> iterate -> create in the portal.
- validate_rule (MCP-local, no backend call): ajv 2020-12 validation against
the baked schemas, guardrail pre-flight mirroring the agent matching
semantics (hive-stripped segment-bounded registry/file prefixes,
whitespace-bounded WMI, exact commands, /-bounded event log channels, hard
blocks), and semantic lint for schema-valid-but-dead rules (unreachable
threshold, non-evaluable factor DSL, unknown eventTypes, unanchored
not_regex allowlists, unresolvable {{tokens}}, trigger cross-fields,
outputEventType conventions/collisions).
- test_analyze_rule: calls the new POST /api/rules/analyze/dryrun and adds
the {{token}}-interpolated explanation/remediation preview via the
existing interpolate-rule-template.
- author-rule MCP prompt guiding scenario -> resources -> draft -> validate
-> dry-run.
- Both tools are visible to every role (backend enforces
TenantAdminOrGlobalReader on the dryrun route); catalog snapshot updated.
- deps: ajv + ajv-formats promoted to direct dependencies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(okf): rule authoring surface concept doc
docs/rules/rule-authoring-surface.md: the AI-assisted authoring stack
(deterministic MCP resources via combine.js baking, MCP-local validate_rule
layers, the dry-run trace twin with its parity + side-effect invariants, and
what is deliberately absent); registered in index.md, logged 2026-08-05.
Customer docs (rules/ai-assisted-rule-authoring.md + SUMMARY + FAQ entry) are
committed in the autopilotmonitor-docs repository separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
phase_change/phase_exit/on_event gather triggers (and the phase-scope
gates) were driven by the pre-reduce SignalIngress.SignalPosted stream,
so any raw signal carrying an EspPhase payload fired them before the
decision engine had applied its gates. In session 32312a32 (RealmJoin
tenant) a Phase Start rule on FinalizingSetup fired at the raw ESP-exit
EspPhaseChanged(FinalizingSetup) signal, 7 minutes before the engine's
RealmJoin-gated phase_transition(FinalizingSetup) - and read
HKLM\SOFTWARE\RealmJoin\Custom\BIOS before the RealmJoin package that
creates it had run (exists=false).
GatherRuleExecutorHost now subscribes to the new TimelineEventStream:
EventTimelineEmitter publishes every emitted event's (eventType, phase)
post-reduce, so phase triggers fire exactly when the UI timeline shows
the phase, and on_event sees engine-emitted event types
(enrollment_complete, phase_transition, realmjoin_resolved, ...) that
never existed on the raw signal stream - restoring V1 semantics and the
documented "On Event with enrollment_complete" pattern. Raw-signal
consumers with OS-state semantics (DeviceInfoHost, UserEspKeepAwakeHost,
ProvisioningPackageHost) stay on the raw feed deliberately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session df1fcf47 (WG Part 1, real ~1h16m) reported 8h15m: one do_telemetry
event inherited an unrelated, 8h-shifted IME log line timestamp via
LastMatchedLogTimestamp, and the backend re-anchored StartedAt on it.
Three layers:
- Agent: OnDoTelemetryReceived now carries the source-line timestamp; the
DO-collector poll path passes null and the adapter stamps the clock
instead of inheriting whatever line matched last.
- Backend: StartedAt alignment and duration-start selection reject
earliest-event timestamps more than 2h before the current anchor
(MaxStartedAtBackwardShift), on all four mutation sites incl. the
SessionsIndex dual-write decision.
- Parser: CMTrace time fields with a UTC-bias suffix ("+480") now parse
and honor the writer-declared offset instead of failing to match.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the E2E finding (95 vs 95.0): a model driving update_tenant_config
had to guess field names and JSON types from a config read. New reflection-
generated schema surface makes guessing unnecessary:
- Backend: GET config/fields-schema (GlobalAdminOnly) - built from the
TenantConfiguration model + the patch service deny-lists at runtime, so it can
NEVER drift from what the patch endpoint enforces. Per field: camelCase name,
JSON type (string/boolean/integer/number), nullability (null clears), format
hints (json / date-time), writability + deny reason, GA-only marker (phase 2),
revert-protected marker. Literal route; FindPolicy literal-over-template
precedence pinned by test (same mechanism as config/all).
- MCP: new strictGa READ_ONLY tool get_tenant_config_schema; update_tenant_config
description + GA instructions headline steer the model to call it before
composing a patch.
- Tests: drift canary (every model property appears exactly once), deny-list
consistency, type exemplars incl. the decimal SLA column class, policy +
route-precedence pins, MCP catalog/registration/request-shape. Backend 3854
green, vitest 465 green, tsc clean.
Deploy order: backend first, then MCP redeploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The green "Preview" badge was the activation-gate whitelist marker from the
preview era. With public availability + auto-approve live it carried no
information. Rows now show the plan tier (legacy "enterprise" resolves to
Pro, everything else Community); the amber "Waitlist" badge still appears
for unapproved tenants.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod finding during the first live E2E (gktatooine): revert_tenant_config failed
fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish
95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns
(decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble
throws on the type mismatch. The rehydrator now forces GetDouble for columns the
model declares as decimal/double/float, mirroring the existing DateTime handling.
The roundtrip canary test is hardened into a reflection filler that sets EVERY
writable property non-default (with a deliberately whole-numbered decimal), so no
column type can escape the fidelity check again; plus a focused pin on the exact
prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused
the write, the snapshot itself is intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first
New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid,
newest-first) holds the raw stored row as EntityJson before any overwriting save of
TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the
single choke point all writers funnel through - fail-soft (a backup hiccup never
breaks a save), skips row creations and noise-only changes (LastAuthClientId flips),
prunes to the newest 2 per partition. Source/reason tagging via a new
SaveTenantConfigurationAsync overload (not optional params - Moq expression trees,
CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged.
Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by
the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant
GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All.
Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): transactional tenant-config field patch + revert with verified writes
New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam):
- PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService:
fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId,
auth provenance; unknown fields 400 with the name) -> patch onto a clone ->
shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read ->
verify that EXACTLY the intended fields changed -> automatic ETag-conditional
rollback on drift (catches Store/Map serialization asymmetry).
- POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot
(latest by default); snapshots current state first (revert is revertible),
preserves plan/homing/provenance fields unless includeProtectedFields.
- GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson.
Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync +
TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook);
validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed,
forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted
so a GA's MCP session never pulls clear-text secrets into model context.
Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes
First mutating surface on the MCP server, strictGa (real Global Admin) only:
- get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never
reach model context; pinned by config-write-tools tests)
- update_tenant_config: field-level patch via PATCH config/{tenantId}/fields;
required reason; response = applied fields + masked diff + backupId
- list_tenant_config_backups: snapshot metadata + masked diff only
- revert_tenant_config: restore latest/named snapshot; protected fields stay
current unless includeProtectedFields; revert is itself revertible
New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts;
role-aware instructions headline (non-GA keeps the exact READ-ONLY wording);
catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference
list (Global Reader/tenant/delegated see none of the four).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window
The heartbeat test held the handler for a FIXED 350ms and expected the 50ms
heartbeat timer to tick >=3 times inside that window. On loaded CI runners the
heartbeat Task.Delay stretches disproportionately to the handler delay, the
handler completes with <3 heartbeats fired, and the awaited condition can never
become true afterwards (CI failure on PR #120). The handler now holds the
message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s
deadline as termination guards), so the assertion is reached deterministically
regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The GA offboard and app-homing confirm dialogs reported failures via the
admin page-level error banner, which sits behind the tenant editor modal
(z-50) and the dialogs themselves (z-[60]) - the user saw nothing. Both
dialogs now take an error prop and render the failure inline; the section
routes homing/offboard failures into dedicated states cleared on open.
Dark mode: class-by-class audit of the offboard UI against the global
override sheet. The dialogs were already covered; the real gaps were
missing family members in globals.css - border-{red,amber,green,purple}-100,
border-{blue,sky,yellow,rose,emerald}-300, and text-red-600/700 (mapped to
red-400, mirroring the cyan/rose 600-to-400 convention).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the [DRAFT] placeholder with the final copy (thank-you, gentle
feedback pointer to GitHub/LinkedIn, welcome-back note, last-email notice)
and remove the OffboardFarewellEmailArmed scaffolding gate. Tests move from
the disarmed-by-default contract to the armed contract (gate ordering,
never-throw, template pins). GitHub links in both email templates now point
to the renamed repo okieselbach/AutopilotMonitor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend: POST config/{id}/trial now denies with 409 ContactEmailRequired
when no contact address is stored (extracted EvaluateTrialStart verdict,
order test-pinned: consumed/already-Pro win over the contact prompt). The
gate is live at the API even while the trial CTA stays behind its
"coming soon" flag; GA plan assignment stays unblocked by design. The
feature-flags payload gains contactEmailSet (bool only, never the
address - contract test updated).
Web: trial CTA shows a "set a contact address first" note instead of the
start button when the flag flips on; dashboard gets an amber non-blocking
banner for Pro tenants without a contact (admins only, fail-safe: only an
explicit contactEmailSet:false nags, so loading/error/older-backend states
stay quiet); GA tenant editor warns when assigning Pro to a tenant
without a contact address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First production auto-approve (mueller-steinag.ch) activated 64s after signup,
5s before the user saved their notification address on the activation page -
the approval path found no address and silently skipped the welcome mail.
Both racing paths now write-then-read: the notification-email save path checks
approval fresh (bypassing the 30s negative cache) and triggers the send when
activation already happened. A conditional-insert welcome-email-sent marker in
PreviewWhitelist makes the send once-only across both paths; it is consumed
strictly after the address check so it can never be burned without an address.
Revoke clears the marker, the GA resend endpoint sends unconditionally and
consumes it best-effort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turbopack drops the ambient space between an inline element and adjacent
prose in certain multi-line JSX text layouts (Babel/SWC preserve it), so
the live portal rendered "onlyto reach you", "IsEnabledflag",
"...ps1script", "5 minutesfor GitHub's", "Unavailableand the cascade".
User-reported on the Contact section; the other five sites were found by
scanning the compiled bundle for space-less text nodes after elements.
Fix: explicit {" "} at each affected boundary - it compiles to its own
text-node and cannot be trimmed. Verified in a fresh build: all six sites
now emit ..."})," ","to reach... Sites whose gap comes from ml-*/mx-*
margins or flex gap were left alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admin tenant editor gains a danger zone that queues the existing
offboarding cascade (DELETE tenants/{id}/offboard, policy already admits
GA). Guarded by a type-OFFBOARD confirmation dialog so a stray click can
never trigger it, Global Admin included.
Both the suspension block and the danger zone now spell out the
semantics: suspension is the durable lock-out lever (blocks sign-in and
auto-activation, data stays); offboarding is NOT a ban - the cascade
deletes the config row including any suspension, so a completed offboard
lets the tenant re-onboard and auto-activate on the next sign-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Double signup on two instances enqueued two auto-approve envelopes; both
workers could pass the approved-check (stale 30s negative cache) and both
ran the activation side effects - duplicate welcome mail plus duplicate
TenantAutoApproved ops event/Telegram ping. Same duplicate on queue
redelivery after a failed message delete.
The whitelist add is now a conditional INSERT (AddEntity, 409 = already
approved) so storage arbitrates concurrent activations: exactly one caller
sees "newly added" and runs mail/promote/ops event; losers drop quietly.
The GA approve endpoint stays idempotent (200 + alreadyApproved instead of
a duplicate mail on re-approve).
Also fixes a silent-failure path: the repo swallowed storage errors and
returned false, which ApproveAsync ignored - a failed whitelist write
still sent the welcome mail and reported success. Storage errors now
throw (queue path retries, HTTP path 500s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two findings from the re-run public-availability review:
- UpdateTenantConfigurationFunction preserved HomedAppClientId, LastAuth*
and plan fields against round-trip clobber but not OnboardedBy, despite
AuthFunction documenting it as write-once: any own-tenant admin PUT could
overwrite or null it, corrupting audit provenance and letting a
revoke -> re-approve cycle auto-promote an attacker-chosen UPN. Now
preserved like the other system-owned fields.
- TenantAutoApproveHandler checked the suspension gate through the cached
TryGetConfigurationAsync (5-min per-instance TTL): a tenant suspended
right after signup could still be auto-approved from a stale view. Now
reads via GetConfigurationFreshAsync; a storage error propagates so the
message retries (flag re-checked per attempt) instead of silently
downgrading the tenant to manual approval.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FlipAsync read the tenant config through the cached GetConfigurationAsync
(5-min per-instance TTL, fail-open to CreateDefault) and saved the whole
entity back: a flip could silently rewind fields another writer changed
within the TTL, and a transient storage read error would have persisted a
default config over the real row. Now reads via GetConfigurationFreshAsync
(cache-bypassing, errors propagate) and aborts loudly when no config row
exists instead of materializing a default one.
Daily-review finding 2026-07-31.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Below md the landing navbar only showed the Get started CTA: Sign in was
hidden sm:block and the nav links (Product/Capabilities/Compare/Docs) had
no mobile counterpart at all. Returning users on a phone had no visible
way to log in.
- burger toggle (<md) opens a solid panel under the bar: nav links,
GitHub, and a prominent Sign in button; closes on link click, backdrop
tap, and Escape
- Get started stays always visible in the top bar; GitHub icon and Sign
in text button move into the menu below md
- panel uses opaque --lp-surface: nested backdrop-blur inside the nav
does not blur page content, a translucent panel let the hero bleed
through
- 320px fit: px-4 below sm and the wordmark hides below 360px (mark only)
Verified via Playwright at 320/390/1280px: menu open/close paths, anchor
scroll to #features, desktop layout unchanged; tsc and ESLint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tenants seeded in the pre-global-partition era can still carry per-tenant
copies of built-in rules (IsBuiltIn=false debris, e.g. ANALYZE-ID-002 on one
prod tenant). They pass the custom-rule filter and put the same RuleId twice
into the merged list, blowing up ToDictionary(r => r.RuleId) in
AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync and silently
dropping rule channel notifications for that tenant.
Guard the merge in AnalyzeRuleService + GatherRuleService: a tenant row whose
RuleId collides with a merged global rule is skipped (global wins) with a
warning log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod report 2026-08-01 (tenant b54dc1af): corporate-identifier validation
"came back on" after every disable. Audit log shows the truth: not a single
True->False entry ever - the disable NEVER reached the server. The validation
section has no save bar, and only the consent-driven ENABLE paths persisted;
the toggle setters (disable via confirm dialog, and enabling the second gate
while the first already carries the consent) wrote plain React state that
silently reverted on the next config load.
New TenantConfigContext.saveValidationGate routes those setter calls through
the shared config PUT with explicit override values; local gate state re-syncs
from the server response (or snaps back on failure), and the section save
indicator covers the write.
tsc clean, vitest 631/631, ESLint ratchet holds (115).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The validation info box and the detect-access error still claimed Application
Administrator could grant the consent. Per MS Learn (grant-admin-consent),
Application/Cloud Application Administrator are excluded for Microsoft Graph
app roles (application permissions) - our consent includes the app-only
DeviceManagementServiceConfig.Read.All. Correct roles: Global Administrator
or Privileged Role Administrator, matching the funnel banner and docs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Post-consent verification now POLLS the access-check (up to 5 attempts,
15s apart) instead of a single shot: right after a successful admin consent
the app-only token often mints with an EMPTY roles claim for 30-90s
(observed live: tenant 5ca2b350, GrantedRoleCount=0 at T+43s), which the
single-shot probe misreported as "permission not granted" while the homing
flip and consent were actually fine. The access-check invalidates and
re-mints fresh per call, so the poll converges as soon as Microsoft has
propagated the role; the consent spinner stays up meanwhile. Final "absent"
message now names propagation as the likely cause instead of sounding fatal.
- Funnel banner gains always-visible actions ("Grant consent for the new app"
+ "Detect existing access" on the autopilot gate, idempotent when already
enabled): tenants whose validations are already ON had no consent button to
click, and the per-toggle detect link under Corporate Identifier would
enable that validation as a side effect.
- Dark mode: funnel/flipped/legacy banners and the admins-only notice were
light-palette-only and unreadable on dark.
tsc clean, vitest 631/631, ESLint ratchet holds (115).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod incident 2026-07-31 18:03Z (tenant b54dc1af): the consent-driven homing
flip landed and 600ms later the frontend persisted the validation toggle via
the generic config PUT - round-tripping a config loaded BEFORE the flip. The
GA branch treated the stale null HomedAppClientId as an intentional revert to
legacy, silently undoing the flip (App Insights: AppHomingFlipped 18:03:00.44,
PUT revert warning 18:03:01.03).
- HomedAppClientId is now IMMUTABLE via the generic PUT for ALL callers, GA
included: a full-model round-trip can never carry flip intent. The only
writers are the consent auto-flip and POST config/{tenantId}/app-homing
(which the GA tenant-management panel already uses).
- HandleAuthClientIdTrackingAsync (same failure class, cross-instance): the
last-seen tracking write now mutates a cache-BYPASSING fresh read via new
TenantConfigurationService.GetConfigurationFreshAsync instead of blind-saving
the up-to-5-min-stale cached view - the login right after a flip is exactly
the one that carries a changed audience and triggers this write.
3 regression tests (fresh-entity mutation preserves a concurrent flip,
fresh-already-current suppresses the write, steady state stays zero-I/O).
Backend xUnit 3767/3767.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plain info box was easy to overlook. Upgrade to a bordered gradient card
with icon and heading, state that admin consent needs a tenant-wide-consent
role (Global Administrator / Privileged Role Administrator), and link the
portal-setup docs article describing the required permissions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.
- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
primary only, probe-gated; GA gets force + revert. Pure decision helper
EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
(AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
GA tenant management (typed homedAppClientId hardens the PUT round-trip),
funnel/success banners in tenant settings with switchAuthApp re-login,
admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
events survive; strategic events across the whole flow (app_homing_flipped,
consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
auth_app_homing_learned, app_homing_manual_flip*).
Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The static MonitoredPoisonQueues watch-list had already drifted twice
(tenant-auto-approve-poison, session-deletion-maintenance-poison missing).
Health check and maintenance watcher now enumerate every existing *-poison
queue in the storage account at runtime; an absent queue has never had a
poison-move and is healthy by definition, so enumeration covers everything
a list would. Enumeration failure surfaces as warning (never a silent
all-clear) on the health card and skips the maintenance tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).
Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
the acting app per tenant (no-op until the legacy config pair is set); Graph
client-credentials, admin-consent URLs and the surfaced client id follow the
homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
the middleware audience builders (was single-audience - would 401 legacy
tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
mcp-server.bicep default client id -> C4A8 app.
Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feature-maturity badges were a leftover of the preview era — with the product
publicly available, a lone "Beta" pill only signals uncertainty. Both features
are fully functional; the dashboard banner already carries the actively-developed
framing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wasAuthenticated was written AND read during render (the only such
case). Historical per-mount state belongs in useState, adjusted during
render via the guarded official pattern — behavior-identical: the value
is only consulted in !isAuthenticated renders, which can only follow a
render that already set it. Auth-gate control flow (auto re-login,
loading early-return, post-login return URL) untouched.
react-hooks/refs: 26 -> 0. Lint ratchet 115.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nine render-phase mirror writes replaced: scope/filter/pagination
mirrors sync post-commit via useLatest; consuming callbacks list the
stable refs in their deps. Drops two write-only refs
(joinGlobalAdminsRef, loadingMoreRef) that nothing read. Imperative
refs (fetchGen/fetchLock/loadAllToken/submittedFilter) untouched.
Lint ratchet 119.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven scope mirror refs sync post-commit via useLatest instead of
render-phase writes; fetchStats lists the stable refs it reads in its
deps. Lint ratchet 128.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four scope mirror refs (routeGlobal, selectedTenantId, tenantId, days)
now sync post-commit via useLatest instead of render-phase writes.
fetchData lists the stable refs it reads in its deps. Lint ratchet
135.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Render-phase ref mirror writes tear under concurrent rendering
(react-hooks/refs). New hooks/useLatest.ts syncs the mirror in a passive
effect instead; refs from custom hooks are not linter-known-stable, so
consumers list them in dep arrays (identity is stable — no re-creation).
Lint ratchet 141 -> 139.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.
CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
First burn-down batch of the React-Compiler hooks warnings (163 -> 141,
CI ratchet lowered to match).
static-components (14, real remount bugs): SortIcon (geographic-
performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/
ResultsDropdown (GlobalSearch) were defined inside render — a new
component type every render, so React unmounted and rebuilt their
subtrees each time (GlobalSearch tore down its dropdown on every
keystroke). All hoisted to module level with explicit props.
purity (6): Date.now() during render replaced with mount-time clocks
(useState lazy init) for the date-input caps (Maintenance sections) and
trial-expiry checks (TenantManagement, SectionTenantConfigReport);
FeedbackSection refreshes its clock per fetch so "Xm ago" labels track
the data; DownloadProgress falls back to NaN instead of wall-clock for
unparseable event timestamps (rate calc already guards via
Number.isFinite).
immutability (5): fetch functions were declared below the effects that
call them — moved above (diagnosis, fleet-health, DistressReports).
This unblocked deeper compiler analysis, surfacing 3 latent
set-state-in-effect findings in those files (93 -> 96); they belong to
the set-state category and stay for its own batch.
Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean,
vitest 611/611, static export builds.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(web): typed routes — broken internal links are now compile errors
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.
- lib/routes.ts: registry helpers return `Route`; withQuery validates the
base path literal. New: deviceBlockUrl (OpsEventsSection previously
hand-built /admin/security/device-block against the registry rule),
route() to compile-check dynamic-section literals (nav configs,
section index redirects), trustedRoute() as the single named seam for
runtime-data hrefs (backend-emitted notification deep links, persisted
post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.
Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): lint findings to zero errors + CI lint gate
Package deal — fixes only land together with the gate, otherwise they
drift right back. CI web job now runs `eslint . --max-warnings 163`:
errors fail outright, the warning cap is a ratchet (lower it when fixing
warnings, never raise it).
Fixed to zero (mechanical categories, behavior unchanged):
- no-unused-vars (49): dead imports/locals removed; where a prop is part
of a live call-site contract, only the destructured binding dropped
- no-explicit-any (29): real structural types for event payloads
(EnrollmentEvent.data et al -> Record<string, unknown> + per-event
interfaces), SignalR passthroughs typed via the library's own
signatures; five consumer files got typing-only follow-through
- no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes
- no-require-imports (5): scripts/**/*.js override — Node CJS scripts,
require() is correct there
- one stale eslint-disable directive removed
Deliberately NOT fixed inline: the React-Compiler hooks rules new in
eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26,
static-components 14, purity 6, immutability 5,
preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs —
each is a per-site behavioral refactor in auth/SignalR-adjacent code, to
be burned down incrementally under the ratchet.
Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc
clean, vitest 611/611, static export builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.
- lib/routes.ts: registry helpers return `Route`; withQuery validates the
base path literal. New: deviceBlockUrl (OpsEventsSection previously
hand-built /admin/security/device-block against the registry rule),
route() to compile-check dynamic-section literals (nav configs,
section index redirects), trustedRoute() as the single named seam for
runtime-data hrefs (backend-emitted notification deep links, persisted
post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.
Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3
react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): react 19 / msal v4 / next 16 code adaptations
- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
(all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
jumping instead of animating (Next 16 stopped suppressing CSS
smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
webpack-only analyzer, use --webpack for ANALYZE runs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): untrack Next-managed next-env.d.ts, generate types in CI
Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): migrate lint script to eslint CLI (next lint removed in 16)
Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
skipTrailingSlashRedirect (added for the next-dev /api proxy 308) also defines
__NEXT_MANUAL_TRAILING_SLASH, which disables the client router's trailing-slash
normalization. Under output:'export' the router then requested RSC payloads as
/<path>.txt while the build only emits /<path>/index.txt — every fetch 404ed and
Next silently degraded EVERY <Link> navigation to an MPA (full-page) load. Most
visible symptom: the sidebar remounted on each click and its expanded groups
collapsed.
- next.config.ts: dev-gate skipTrailingSlashRedirect (prod build normalizes
links to /foo/ again; RSC fetches hit the emitted index.txt files)
- GlobalSidebar: normalize usePathname() (client nav now yields /foo/, hard
loads /foo — exact matches against the slash-less nav hrefs need both), and
re-run the auto-expand effect when visibleExpandableGroups arrives so a hard
load (deep link, F5) still expands the active group once MSAL resolves
Verified: static export + local serve, marker survives Link nav (no document
request, /terms/index.txt 200); tsc clean; vitest 611/611.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark
Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
-> 09:43 MCP terminal demo (full session report via get_session_summary +
get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
(now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
hostRouting) and staticwebapp.config.json unchanged
Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): landing feedback round — linear story timeline, honest examples, dev stats
- Story is now one vertical timeline with a strict top-down reading order
(time rail left, text left, visual right) instead of the zigzag grid;
act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
real figures or hides entirely on fetch failure (never fake numbers,
never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
"Pull ETL and event logs"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v3 — mission-control cockpit instead of single-session card
The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit
Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header
- Analysis-finding overlay removed: the dashboard shot stands alone (was
colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
rescripted from a real production analysis (anonymized): failed attempt
with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
duplicating the terminal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot
"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hero v6 — portal-first layout from approved design draft
- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
sub left / CTA group right with trust line, portal shot nearly full
width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
detected" replaces success rate (underlines automated analysis),
events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
the band
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing
- New public /get-started page: five-step schematic (sign in -> request
access -> consent -> deploy bootstrapper -> watch live); "Get started"
in navbar and hero now lead here, "Sign in" keeps the login handoff —
resolving the two-identical-CTAs problem. Wired into hostRouting
PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
adaptive card has exactly one action ("Open session") — the invented
"Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
shadow only. Final CTA rebuilt as a calm two-column band per draft
(no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
under .dark .landing-v2, theme toggle removed from LandingNavbar
(screenshots are light; portal keeps full dark support).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): about/terms/privacy on the landing shell — no more sidebar flash
The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): landing navbar anchors root-anchored so they work from subpages
#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): calmer CTA green, about de-blued, public-page typography aligned
- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
switch from bright #33b161 to the deep accent-ink green — matches the
dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
to the single green accent, audience cards/step bubbles/links/quick-link
hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
smaller page h1s and section headings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): terms callouts on lp tokens — no more stray blue/amber banners
Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): about tech stack — drop version numbers, fix stale SSR claim
Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): terminal demo app renamed to Contoso CRM App
Neutral fictional app instead of a vendor-adjacent print driver name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started
- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
CTAs; sign-in stays in the navbar and on /get-started itself
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal brand aligned to landing, subtle Enterprise anchoring
- Portal navbar (both variants) now uses BrandMark + the landing wordmark
style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
subline + /get-started closing note ("same service, nothing to
migrate") + "Plans" link in the footer Product column, all pointing to
the docs plans page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): final CTA right-aligned, get-started enterprise note "and more"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): social images refreshed to current hero claim and tagline
"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): social card wordmark single-color, matching the site
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal sidebar selection states green — brand-aligned like the docs
Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links
- About/terms/privacy no longer render half dark for dark-mode visitors:
the light-only marketing scope now neutralizes the global .dark utility
overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
.landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
just-ask, capabilities, comparison, how-it-works now share the same
left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
(old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
and portal status texts intentionally unchanged)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned
- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
accent notify box, neutral next-steps, ghost actions — "Preview"
wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): preview page — prominent approval-pending badge, honest status copy
- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
blocked tenant never reaches a dashboard — signing in again lands back
on this page while pending, and goes straight into the portal once
approved (verified against AuthContext PrivatePreview handling)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): about/terms/privacy cards flattened to the landing pinstripe style
All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon
- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
green-600/700: admin backups, customs archive, vulnerability data,
session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
(light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): portal links and focus rings on brand green
- All focus states unified: focus:ring/border blue-500 and indigo-500
(207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
variants (36) -> text-green-700 hover:text-green-800; icon hovers
hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): local dev sign-in repaired; progress portal brand accents green
Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
/api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
rejects trailing-slash routes, so /me silently failed.
-> skipTrailingSlashRedirect: true (no effect on the static export;
SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
healthy (v1.5.1027).
Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): loading screens and remaining primary buttons on brand green
- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
retry/back buttons, error page, audit, diagnosis, session table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): last blue interactive states green; monitoring headers wrap on mobile
- Time-range toggles (fleet health, software, usage, dashboard, platform
usage), software lens tabs, MCP-usage segmented controls, SLA report
button, gather/analyze rule save buttons, and the page-header icons on
fleet health / geographic performance -> brand green. Zero remaining
bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
geographic performance, audit): title row gets flex-wrap + gap-y-3 and
the control group (tenant scope + range buttons) wraps as a compact
second line on narrow screens instead of pushing off the right edge;
range buttons slightly compacted (px-3 py-1.5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): geo map dev crash — per-mount container key for Leaflet
"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): form control accents (radios/checkboxes) green portal-wide
Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): pin native form-control accent-color to brand green
Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): progress portal icon inherits item color again
Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text
- Root cause of "Map container is already initialized": Next 15 app
router runs its bundled React 19 regardless of the installed react@18,
and react-leaflet 4.x is incompatible with React 19 StrictMode
double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
(@react-leaflet/core 3) and removed our now-counterproductive
workarounds (MapCleanup manual remove, per-mount container key) — v5
owns the map lifecycle including unmount teardown. Verified via a
throwaway repro page: map renders, remount toggle survives, console
clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
#8896ad) — notification timestamps and other muted text were nearly
invisible on raised dark surfaces (unread bg, hover rows).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): subtler dark hover surfaces — hovered rows stay readable
.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides
The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): notification row hover clearly distinguishable in dark mode
GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): one SegmentedControl for all range/group toggles
New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): MCP usage sections use the shared SegmentedControl too
The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: clickable notification deep links in the bell dropdown
GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): npm overrides pin react-leaflet peer deps to installed react
react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two defects of the same shape in the portal→www direction:
- Deterministic: a signed-in user opening portal root was always bounced to
www (guard effect fires at hydration, before MSAL restores the session, so
AuthGate never got to route "/" to /dashboard).
- Race: MSAL's module-level init strips the #code hash before hydration, so
the auth-response exception saw nothing and the bounce destroyed the
in-flight token redemption — the user ended up unauthenticated on www.
Portal→www now waits for settled, unauthenticated auth state; www→portal
stays auth-free. Decision extracted as pure decideHostBounce with the safety
rules pinned in hostRouting.guard.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New "phase" stage: OnPhaseChanged writes a transition marker line
(phase change: Old -> New) so rule-level lines read against the current
phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
and capture groups (<=100 chars, same selection as event data), capped at
10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.
Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.
Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.
/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.
Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.
- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
triggered the MSAL redirect), usage-metrics, fleet-health, sla and
admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
pages) left untouched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:
- session.csv: append the 15 newer Sessions fields that had drifted out
of the export (failureSource, reconcileReason, adminMarkedAction,
validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
eventStreamActive in the request and report-metadata.json; the modal
warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
attachments status in metadata instead of silently dropping invalid
base64, SanitizeZipEntryName strips path components from customer-
supplied file names (both report types)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.
- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
report), reuses the Hosted/CustomerSas read paths, re-verifies the
hosted tenant prefix via ClassifyBlobName, honours the admin size cap
and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
(written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).
Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".
Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.
Web tsc + 603/603, backend build clean + SLA suite 38/38.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
HostRoutingGuard's replace, signing the user in on the wrong origin.
ProtectedRoute now stands down on the public host and lets the guard move
the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
including the automatic portal-side re-auth where Entra's session cookie
would complete silently. login({auto:true}) (ProtectedRoute) drops the
prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
keep the picker.
Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.
Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
no path — a bounce destroys the sign-in), plus a sessionStorage bounce
counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
window.location.href navigation (getPortalLoginUrl pattern) instead of
router.push + guard cascade.
tsc clean, vitest 603/603, export build 87 pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.
- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
/diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
/admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
consolidated. Suspense boundaries added everywhere useSearchParams drives
page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
the *NavSections registries, dynamicParams=false, client bodies extracted to
SectionClient) — 37 pages; server redirect() index pages became
ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
webhook notifications cannot be edited): SWA wildcard rewrites serve the
family page and the globally mounted LegacyPathRedirect rewrites to the
canonical query URL, preserving query + #event hashes (mapping pinned by
lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
override, all security headers incl. literalized prod CSP (no unsafe-eval),
Content-Type routes for the extension-less og images. Guarded by the new
utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
(query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.
Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.
- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
(transport-derived, unspoofable; IPv4-mapped addresses normalized).
Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
(FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
Used by GetBootstrapScriptFunction (traffic arrives via
go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.
- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
raw string; golden test pins byte parity with the TS template
(TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
/api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
section, index/log entries.
Backend tests 3611/3611, web vitest 651/651, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The HTTP/1.1 / six-connection-cap claim was wrong — it came from the
Schannel curl build that cannot speak HTTP/2. ALPN (openssl s_client)
and .NET HttpClient confirm the portal negotiates h2; the queue is
server-side in the SWA SSR runtime. Doc, index, log and the
GlobalSidebar comment now describe the real mechanism.
Adds the 2026-07-28 outcome section: Standard plan changed nothing
(stratified p50s match, Mann-Whitney p=0.81); prefetch={false} removed
the freezes (worst totalDuration 225s -> 4.9s) and also removed an
accidental keep-alive (~150ms added to cold navigations).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customer feedback: backfilled modern_deployment_error events (pre-agent
Autopilot event-log errors replayed at agent start) rendered as plain
Error rows with one identical emission timestamp — indistinguishable
from a live failure.
Web (EventTimeline.tsx, effective for already-stored events):
- "⟲ Backfilled" badge (slate, explanatory tooltip) on data.backfilled
- row time now shows the original event-log timeCreated with a
"(reported …)" suffix; metadata block gains a "Recorded" line;
raw mode gets a compact ⟲ marker
- severity deliberately NOT downgraded — unresolved pre-agent errors
must stay visible in the Error filter
Agent (ModernDeploymentTracker):
- backfill scan buffers the capped record set, emits an Info summary
event first (modern_deployment_log, backfillSummary: true +
replayedCount/droppedOverCap/lookbackMinutes) announcing the block,
then replays oldest-first so timeline sequence follows event-log
chronology (cap semantics unchanged: newest 25 win)
No new EventType, no ConfigVersion bump. Tests: summary emission x2,
tracker subset 12/12; tsc + vitest 651/651 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.
- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
failures swallowed, optional console echo), GatherRuleContext.DebugLog
(null-safe no-op), trace points in executor + collectors; never writes
under _scopeLock (reasons captured inside, written after release);
merger bool->path projection with --gather-debug-log CLI override
tolerance; deliberately NOT in the offline-cache strip lists;
--run-gather-rules always traces; zero-rules delivery leaves a one-line
file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upload the diagnostics package as a pre-provisioning intermediate when
Part 1 seals (suffix "preprov"). No new config field: the existing mode
semantics carry the desired behaviour — Always uploads at the seal,
OnFailure skips (the seal terminates as Succeeded; a Part-1 failure goes
through the regular Failed terminal path, which already uploads), Off
stays off. The upload runs before the whiteglove_part1_complete emit so
diagnostics_* events keep a lower sequence than the Part-1/Part-2
boundary marker. Best-effort: a fast reseal-reboot just loses the
intermediate package. Backend needs nothing — ingest already stamps
diagnostics_uploaded independent of terminal state (proven live by the
on-demand collection path).
Also make the on-demand path honest: CreateAndUploadAsync now takes
bool? enrollmentSucceeded — null means "no outcome yet" (mid-session),
so the OnFailure gate only skips a KNOWN success and sessioninfo.txt
records "In Progress" instead of the former enrollmentSucceeded:false
workaround that stamped in-flight packages "Failed".
10 new tests (WG seal mode matrix, preprov suffix, event ordering vs the
Part-1 marker, unchanged Part-1 exit contract; nullable-outcome gate
matrix via HTTP-free sentinel, sessioninfo labels). Agent suite
2112/2112 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent already reports connectionType in every network_interface_info
event, but it was only stored as a JSON blob in DeviceSnapshot — answering
"how many machines enroll over Ethernet vs WiFi?" required a slow full-table
scan via deviceProperties. Project it as a typed column instead (template:
the AvgApiLatencyMs chain), making it a cheap indexed one-call query.
- Ingest: TryExtractConnectionType — last network_interface_info emission of
the batch wins (a device switching media mid-enrollment is relabeled);
only exact "WiFi"/"Ethernet" accepted, fire-and-forget fail-soft writer
- Storage: UpdateSessionConnectionTypeAsync (Merge on Sessions + index
mirror, tombstone-safe), BuildSessionIndexEntity superset entry,
MapToSessionSummary, re-registration preserve in StoreSessionAsync
- Search: connectionType= filter with OData eq push-down on the index,
client-side backstop on the non-OData paths, fields=connectionType
projection, pagination fingerprint entry, raw endpoint parity
- MCP: connectionType z.enum param on search_sessions/query_raw_sessions,
fields doc updated (server redeploy required)
- Web: optional sortable/filterable "Connection" column in the session list
- No backfill: legacy sessions lack the column and are excluded by the
filter (intended, same semantics as RebootCount); the deviceProperties
scan path still covers historical data
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derived the language set from actual enrollment geography
(get_geographic_metrics, 365d, 10k sessions across ~50 countries) and
documented the country->language mapping on the key arrays. Newly
covered: Finnish, Hungarian, Lithuanian, Latvian, Romanian, Croatian/
Bosnian, Slovak, Indonesian/Malay, Vietnamese, Thai, Arabic, Bulgarian
radio-type variant and Swedish "Radiotyp". New fi/id parser tests; the
unknown-language tests now use Greek (outside the geography set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the localized label sets with the remaining languages seen in
enrollments: Russian, Japanese, Chinese (Simplified + Traditional,
incl. the 信道/通道 channel variants) and Korean (incl. the two radio-
type spellings). SSID stays untranslated everywhere. New zh-CN and ko
parser tests; suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The netsh fallback previously matched English labels only, silently
dropping fields on localized OS images. The parser now matches the
label variants of the languages commonly seen in enrollments
(EN/DE/FR/ES/PT/IT/NL/PL/CS/TR; Scandinavian locales reuse the EN/DE
spellings) and is split into a pure Parse(output) function covered by
unit tests (EN/DE/ES samples, "94 %" spacing, AP-BSSID collision,
unknown-language degradation - SSID itself is untranslated in all
locales). Unknown labels still fall through harmlessly; the native
WLAN API remains the primary path.
V2.Core suite 2095/2095 green; fallback live-verified against netsh
on dev hardware (identical values to the API path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field regression on 2.0.1383 (session f7c56065, HP ProBook 450 G7,
build 26200.8037): the native WLAN API path returned nothing in the
OOBE/SYSTEM context and the null path was silent, so wifi_signal_info
disappeared entirely - while netsh-based 2.0.1380/1382 sessions on the
same build kept reporting SSID/signal the same morning. Suspected (not
yet proven) cause: Wi-Fi location gating of WlanQueryInterface on
24H2+; inbox netsh is unaffected.
- WifiInfoReader: new TryGetCurrentConnection overload reporting WHY
the read yielded nothing (native rc per step, interface state);
callers log it at Debug so the agent log explains missing WiFi info.
- NetshWifiFallback (new): parses netsh wlan show interfaces ONLY when
the WLAN API yields nothing on a WiFi-connected device; SystemPaths.
Netsh restored for it.
- WifiConnectionInfo.SignalPercent is now int? (netsh fallback cannot
always parse localized "Signal"); event payload emits the field only
when present - same shape as before.
V2.Core suite 2085/2085 green; interactive positive path re-verified
(SSID/94%/802.11ax/ch116, empty diagnostics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Narrows the column so more columns fit on one page; matches the
two-line style of Device/Model/Status cells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
netsh wlan show interfaces output is localized: "Radio type"/"Channel"
(and on some languages even "Signal") never matched on non-English
images, silently dropping fields from wifi_signal_info and network
change snapshots. It also spawned a child process per collection.
Replace both netsh call sites with wlanapi.dll P/Invoke
(WlanQueryInterface: current_connection + channel_number):
- New WlanNativeMethods (raw interop) + WifiInfoReader (fail-soft,
prefers the active NIC's interface GUID, falls back to the first
connected WLAN interface; returns null on machines without WLAN).
- DeviceInfoCollector.CollectWiFiSignalInfo and
NetworkChangeDetector.CollectWiFiInfo now use the reader; RadioType
strings stay netsh-compatible ("802.11ax" ...), event fields
(wifiSsid/wifiSignalPercent/wifiRadioType/wifiChannel) unchanged.
- SystemPaths.Netsh removed (no remaining references).
V2.Core suite 2085/2085 green incl. new WifiInfoReader tests; struct
marshaling verified against native sizeof (604/68/36/532).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Off by default; sortable via avgApiLatencyMs. Data comes from the
SessionsIndex mirror the list already reads — no extra query or table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fleet data (2026-07-28, agent 2.0.1380) showed the late-start detector was
outcome-blind: tenant 659c3a90 collected 12 Warnings with the post-mortem/
hung-script note on enrollments that succeeded — the bootstrap merely ran
late in the ESP queue and the agent observed the healthy final minutes live.
New pure helper ObservationCoverage.DescribeLateStart:
- Succeeded -> Info with a tail-only-coverage note (IME-replay hint,
bootstrap-ordering review if recurring); no alarm framing.
- Failed/TimedOut -> Warning with the existing post-mortem/hung-script note.
- oobeStateAtAgentStart refines the message only: "completed" = pure
post-mortem, "in_progress" = live tail observed; not_started/unavailable
add no claim (value flips to not_started after mid-OOBE reboots and
"completed" is routine on post-desktop restarts).
The coverage gate (10 min boot-to-start / 5 min uptime) is unchanged, and
the OOBE state is deliberately NOT a gate: the canonical hung-script
failure (f148976f) terminates on the still-shown ESP screen, i.e. with
OOBE reading in_progress.
8 new tests (outcome x oobe-state matrix); V2.Core suite 2067 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SearchSessionsFunction fields whitelist += avgApiLatencyMs,
apiRequestCount so search_sessions lean sweeps (e.g.
fields=geoCountry,avgApiLatencyMs,apiRequestCount) can aggregate
latency without full SessionSummary payloads
- search_sessions tool doc lists the new fields incl. the weighting
hint; get_geographic_metrics doc names avgApiLatencyMs /
apiLatencyVsGlobalPct as the backend-region distance metric
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The outbound_ip trace event stays available for analysis (timeline,
MCP); only the UI row is removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extra grid cell in the Session Info card forced a fourth row once
Device Validation was present. The latency reading is a "when needed"
detail, so it moves into the collapsed Device Details card as a new
Connectivity section instead — together with values that were collected
but never displayed anywhere: the estimated internet bandwidth
(network_bandwidth_estimate incl. DO-throttle hint), total telemetry
upload volume (agent_metrics_snapshot), and the outbound IP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces the persisted per-session AvgApiLatencyMs in the geographic
views so operators can compare regions when deciding on new backend
locations (Asia/US):
- ComputeGeographicMetrics: per-location and global request-weighted
latency (sum(avg*requests)/sum(requests)) + ApiLatencyVsGlobalPct;
serves both the tenant and the GA cross-tenant endpoint
- GeoMetricsSessionProjection extended (column-projected scan would
otherwise drop the new fields); equivalence test pins it
- Drilldown: LocationSessionRow carries the per-session values; sessions
table + summary tile show them
- geographic-performance page: sortable "API Latency" column with
absolute color buckets (<250 green .. >=800 red), global benchmark
- PlatformMetricsService: session AvgLatency now exact from cumulative
counters when present (fallback: mean of interval averages)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The V2 agent already measures HTTP round-trip latency per request
(NetworkMetrics, cumulative _totalLatencyMs) but only emitted the
per-interval delta average. Operators deciding on new backend regions
(Asia/US) need the session-wide average, aggregatable by GeoCountry.
Measured baseline: Jakarta sessions average ~600-1050 ms client-side vs
94 ms server-side processing — the gap is pure network distance to GWC.
- Agent: agent_metrics_snapshot now carries cumulative net_total_latency_ms
(total/requests = whole-session average; idempotent by construction)
- Ingest: last matching snapshot of a batch projects AvgApiLatencyMs +
ApiRequestCount onto the Session row (fire-and-forget, fail-soft,
last-write-wins; agents predating the field simply never match)
- SessionsIndex: both fields mirrored (merge + BuildSessionIndexEntity
superset) so list/GA views can aggregate latency per geo
- Web: session detail shows "API Latency" when present
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET metrics/ime-versions is MemberRead and the archive is one global
partition, so every tenant member reads the same platform-wide rows. The only
thing between an ordinary tenant user and another tenant's identifiers was an
inline projection with no test behind it.
There is no HTTP-level harness in this suite - nothing constructs an
HttpRequestData with a RequestContext - so the projection was unreachable from
a test while it lived inside Run. Extracted it to a static
BuildResponsePayload(versions, hasGlobalScope), matching the pattern
GetTpmPssUnsupportedFunction already uses for the same reason. Behaviour is
unchanged: the payload is returned as `object`, and System.Text.Json resolves
an `object` declared type via the runtime type, so both branches keep the wire
format they had inline. A test asserts that rather than trusting it.
10 tests, asserting on serialized JSON in camelCase (Program.cs's naming
policy) because a property dropped from an anonymous type is only a real
redaction once it is absent from the wire:
- non-global: firstSeenTenantId / firstSeenSessionId absent, and their GUID
VALUES absent anywhere in the payload (a model rename must not let the
identifier survive under another key)
- non-global: exactly {version, firstSeenAt, lastSeenAt, sessionCount}
- non-global: sessionCount survives - the counts are platform-wide by
decision, pinned so it cannot drift either way unnoticed
- global (Admin or Reader): full entry including both identifiers
- both scopes: array shape, empty archive, row order preserved
Mutation-checked both directions: forcing the global branch fails 3 tests,
dropping sessionCount from the projection fails 2. Full suite 3521 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worker serializer omits null fields (WhenWritingNull), so the
aggregated global/metrics/device-journeys response carries no
repeatDevices at all. The strict `=== null` check missed undefined and
fell through to repeatDevices.length, crashing the page. Verified
against the deployed chunk: crash column 11464 is the `0===o.length`
read behind the `null===o` guard.
Loose `== null` check + optional DTO field; absent and null both render
the "select a tenant" hint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Defender for Cloud reports CVEs against the mcp container image that the
repo scanners cannot see. The subassessment evidence paths split them in two:
/usr/local/lib/node_modules/npm/** - the npm 10.9.8 that node:22-slim
bundles, vendoring @sigstore/core 2.0.0, brace-expansion 2.0.2, tar 7.5.11
(critical), sigstore, picomatch, ip-address, plus npm itself (high, no fix
available). Present in all 20 images including the current one. Dependabot
reads package-lock.json, so none of this is visible to it.
/app/node_modules/** - body-parser 2.2.2, fast-uri 3.1.2, hono, protobufjs,
tar 7.5.16, all confined to images from 2026-07-21 and earlier and already
fixed by 7df208fa/c3e9621a. Those findings age out with the old ACR tags.
npm is build-time only here: CMD is `node dist/index.js`, the HEALTHCHECK is
`node -e`, the container app spec overrides no command and probes over HTTP,
and src/ contains no child_process call. So remove npm rather than upgrade it
- npm 11.x still vendors @sigstore/core 3.1.0, itself inside the vulnerable
<= 3.2.0 range, and would clear nothing.
Separately, tar 7.5.20 -> 7.5.22 in our own tree (GHSA-r292-9mhp-454m, via
@huggingface/transformers -> onnxruntime-node). 7.5.22 satisfies the declared
^7.0.1, so no override is needed. fast-uri deliberately stays on 3.1.4: it is
patched against all four fast-uri advisories, and ajv 8.18.0 declares ^3.0.1,
so forcing 4.x would be an override against a major range.
npm audit --omit=dev: 0 vulnerabilities. tsc clean, 455 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.
Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.
Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).
Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Navbar + GlobalSidebar render the full navigation on every page, so the
App Router's default viewport prefetch fired one ?_rsc= request per
visible link against the SWA runtime (~10.4k requests / 14 days). That
origin serves document, chunks and RSC payloads over HTTP/1.1 (6
connections per origin); when the runtime queued the prefetches (p95
896ms, tail up to 234s), they held every socket and the portal froze
until the queue drained. Measured on the 2026-07-27 17:25 incident:
/admin/presence?_rsc= at 151s while the backend API answered in <2.2s.
All 16 nav links now set prefetch={false}. Every target route is a
client component behind ProtectedRoute that fetches its own data after
mount, so the prefetched RSC payload carried no benefit.
New OKF doc docs/web/portal-navigation-prefetch.md records the full
evidence chain and where browser telemetry lives (separate App Insights
resource, not reachable via MCP query_backend_logs).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A GA with a persisted tenant override opening /usage-metrics (or /sla) saw
their OWN tenant's stats until a manual refresh. useGlobalAdminScope seeded
the persisted selection in a post-mount effect, so the page effect first saw
effectiveTenantId = own tenant and fired an own-tenant fetch immediately
followed by the override fetch; with raw authenticatedFetch (no latest-wins
guard, unlike useAuthenticatedFetch since 82934a37) the last-RESOLVED
response won.
- useGlobalAdminScope: seed the GA selection render-time (same pattern as
the delegated seed) so consumers never observe a transient own-tenant
effectiveTenantId - the wrong fetch no longer starts at all.
- usage-metrics/sla pages: latest-wins sequence guard in fetchMetrics so a
superseded request can neither write data/notifications nor clear the
newer request's loading state (covers rapid selector switches).
Verified: tsc 0 errors, vitest 651/651.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three defects found by measuring production after the 1.5.1012 deploy:
- AppMetricsProjection dropped TerminalState, so the PR0 skip
classification never reached metrics/app (totalSkipped stuck at 0).
Column added; equivalence fixture now populates TerminalState/AppId/
EspBlocking/AppIdCollision and asserts them (proven: test fails
without the projection fix).
- BuildTimeAttributionAggregates excluded breakdowns on ANY quality
flag; 903/1000 carry BlockingSetUnknown, starving fleet stats
(Clean=12 vs 331 excluded on the largest tenant). New
TimeAttributionFlagQuality.DurationCriticalFlags mask - only
ClockSkew/PartialObservation/WGAnchors exclude; blocking-set flags
keep their measured spans (positive-evidence semantics unchanged).
- HasMeasuredDuration gets an upper plausibility bound (6h = default
AgentMaxLifetimeMinutes): 490 rows carried back-stamped completions
(up to 117 days; 60175s app rows in a 2697s session) that dominated
slowestApps with six-figure averages. Read-side, heals old + new rows.
Backend 3484/3484 green; solution build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tab still running the previous deployment's bundle requests lazily-loaded
chunks (next/dynamic charts, route chunks) whose content-hashed filenames no
longer exist after the SWA swap - the user is stuck on "Loading chart..." or a
dead navigation until a manual refresh. Standard remedy: reload once so the
fresh document references the new bundle.
- utils/chunkReloadRecovery.ts: chunk-error detection (webpack ChunkLoadError +
Chromium/Firefox/Safari dynamic-import variants) and a sessionStorage guard -
at most one auto-reload per tab per 5 minutes; without working storage no
auto-reload at all, so a reload loop is structurally impossible. Emits a
ChunkReloadRecovery App Insights event before reloading.
- components/ChunkReloadRecovery.tsx (root layout): catches rejected dynamic
imports via unhandledrejection and failed /_next/ script/link tags via a
capture-phase error listener (third-party scripts never trigger a reload).
- app/error.tsx: a chunk error reaching the boundary reloads once instead of
showing the error card; inside the guard window it falls through to the card.
Verified: tsc clean, vitest 651/651 (8 new tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GA override on gather-rules called GET /api/global/config/{tenantId}, a route
that does not exist in the backend; the swallowed 404 left the unrestricted
badge showing the GA's HOME tenant's mode for every foreign tenant. Both paths
now read the member-readable config/{tenantId}/feature-flags (MemberRead has a
global-scope bypass and the route is RouteParam-scoped), with a reset-on-switch
so a failed fetch never carries the previous tenant's mode over. The dead
globalConfig.tenant entry is removed from the URL register.
useAuthenticatedFetch now enforces latest-wins: the scope hook seeds a
persisted foreign tenant only after mount, so an own-tenant fetch races the
override fetch and the last-RESOLVED response used to win, leaving the rules
list on the home tenant while the selector showed the override. Stale requests
no longer write data/error/loading; the spinner is owned by the last-started
non-silent request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customers who additionally deploy Microsoft 365 Apps as their own Win32 app saw two
rows in Install Progress (the IME view and the Office C2R lifecycle) with nothing
telling them apart, which read as a duplicate.
- New lib/installProgress.ts: buildInstallItems() extracted from the component so the
event-folding rules are unit-testable; component is now render-only.
- InstallItem gains `source` (ime | office-c2r | realmjoin) and a source-scoped `key`.
Rows were keyed by app name alone, so a Win32 app named exactly "Microsoft 365 Apps"
(= OfficeInstallDetector.AppName) collided with the C2R row and overwrote its state.
- Pill only for non-default sources: "Click-to-Run" / "RealmJoin", neutral grey so it
does not compete with the state badge, with a tooltip explaining what the row measures.
Plain Intune app rows stay unlabelled.
- Drops the "RJ: " name prefix, which broke sorting and truncation.
Verified: tsc clean, vitest 643/643 (16 new).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex review (P2): the ESP-config refresh triggers each run on their own
Task.Run (as do CollectAll's startup sweep and CollectAtEnrollmentStart),
so two concurrent collects could interleave read-before-emit - the LATER
emission then carried the OLDER (smaller) registry snapshot. The backend
uses the latest list-bearing esp_config_detected emission as positive
blocking evidence, so an out-of-order emission would silently shrink it.
CollectEspConfiguration now runs its read->emit sequence inside a
dedicated lock covering ALL callers; a later emission always carries an
equal-or-newer snapshot. The race predates the Q2 refresh triggers (they
only raised its frequency).
Deterministic concurrency test: 12 parallel refreshes against a probe
whose list grows per read - the emission sequence must be exactly 1..12.
Verified: Agent V2 Core 2059/2059.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All six review findings verified against code and real; five fixed here,
the agent-side race lands in the companion agent commit.
[P1] WhiteGlove pause counted as time: ClampToWindows clipped intervals
against the HULL of both WG windows, but the excluded pause lies inside
the hull - a reboot gap or app interval straddling the pause counted the
entire pause (a ~2-day "reboot" in a 30-minute enrollment was possible).
Seconds are now the per-window intersection sum (chronological endpoints
kept); an interval wholly inside the pause makes no claim and no longer
raises ClockSkewDropped. What-if savings are additionally capped at the
session's WallClockSeconds - endpoint deltas can still straddle the
pause, and no removal can save more than the enrollment took.
[P1] Late-arriving events never updated breakdowns: the terminal-seam
compute is one-shot and the sweep only healed missing/version-stale rows.
SessionTimeBreakdown now stores EventCountAtCompute (0 = pre-fix row,
recomputed once); the sweep recomputes on EventCount drift and then also
re-runs ResolveEspBlockingForSessionAsync (newly exposed on
IMetricsRepository) so late esp_config_detected re-emits and app batches
reach both the breakdown and the blocking evidence.
[P2] Stale aggregate buckets: the sweeps now reconcile every partition
they wrote - buckets that existed but were not regenerated (sessions
deleted, class left the window) are deleted via new targeted deletes;
rolling30 rows additionally get a 48h ComputedAt freshness filter at
read time, so a fully quiet tenant stops serving year-old numbers as
"last 30 days".
[P2] Date windows included an extra calendar day: both inclusive range
ends meant days=30 returned 31 keys and days=1 summed yesterday AND
today. InclusiveWindowStart(today, days) = today-(days-1) in both
response builders; the sweep's rolling30 source is filtered to the same
30 calendar days (its query window stays one day wider for backfill).
[P3] Device-history banner counted later sessions as "previous
enrollments" when viewing an older session: selectPriorSessions
(deviceHistoryPrior.ts) anchors on the session's own chain entry (live
sessions: new sessionStartedAt prop), sessionId tie-break mirroring the
server chain sort.
Verified: solution build clean, Backend 3480/3480 (+6: three WG-pause
golden fixtures, window-start facts, RK builder/delete consistency,
EventCountAtCompute roundtrip), Web tsc + vitest 633/633 (+6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Q1 (AppInstallTiming): a terminal->active transition (IME retry path,
e.g. Error->Downloading or Skipped->Downloading) re-arms CompletedAtUtc
so the final terminal payload covers first-start -> last-terminal instead
of freezing at the first terminal state. Plus a negative-duration guard
in AppInstallTiming.DurationSeconds (inverted endpoints -> null, field
omitted) - observed in production as durationSeconds=-2342 on a
Skipped-then-retried user-scope app (session 66f1c5e1).
Q2 (esp_config_detected): the ESP blocking lists are read too early -
device lists grow progressively, user-scope S-<SID> lists appear only
after sign-in (88 of 89 prod sessions had an empty user list at last
emission). DeviceInfoHost now re-collects the ESP configuration as
phase-driven one-shots: apps sub-phase declaration (AppsDevice/AppsUser
phase_transition = first app activity) and AccountSetup detection.
The StartupEventGate keeps unchanged payloads silent (fingerprint covers
list contents), so only grown lists actually re-emit. F1 EspBlocking
resolution (latest list-bearing emission) benefits without backend change.
Q5 (HardwareInfo): WMI values (serial, manufacturer, model) trimmed at
the source via NormalizeWmiValue; whitespace-only collapses to the
"Unknown" sentinel the retry logic already treats as unresolved.
Attach-window investigation (11a3b67a): NO agent fix - hypothesis
disproved against two real agent logs (diag bundles db5ce007/2.0.1337,
4a9e9273/2.0.1379). "completed without started" first apps were already
installed (WinGet detection-only, zero install lines in AppWorkload.log);
pre-attach replay provably works (pattern-match log). PR0's
HasMeasuredDuration already handles the class server-side.
Duplicate app_install_completed (66f1c5e1): mechanism narrowed to an
in-process re-read of exactly one AppWorkload rotation window (<24h-old
lines, historic suppression not applicable) - documented as follow-up,
no fix without a bundle-bearing repro per lessons.md.
Verified: solution build clean, Agent V2 Core 2058/2058 (14 new tests),
DecisionCore 493/493. No ConfigVersion bump, no new event types,
decision rail untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Analyze rules only (gather stats stay per-batch-deduped until Q6 is
fixed - never silently included). Per (tenant, rule) the radar compares
the trailing 7-day hit rate (FireCount/SessionsEvaluated from the daily
RuleStats rows) against the prior 28-day baseline and fires only when
the window has >=5 hit sessions and >=20 evaluated sessions, the rate
is >=2x baseline AND the Wilson 95% intervals are disjoint
(MetricsMath.WilsonInterval/RateIncreaseSeparated, hand-computed test
vectors). Suppressed for deleted rules, rules inside the CreatedAt
grace period (<35d), rules edited inside the window, empty baselines,
and the RuleRegressionRadarDisabled app-setting kill switch.
Alert episodes live as ruleregression|{ruleId} rows in the notification
tracker (third keyspace): AddEntity-409 dedup = one bell + one
RuleFrequencyRegression ops event (category Tenant, Warning, full
numbers, dual-registered in the web OPS_EVENT_TYPES) per episode;
numbers refresh while active without moving FirstNotifiedAt; episodes
re-arm under 1.5x baseline, on fire-stop, or via the tracker's 30d
retention prune (spec-intended reminder). On-fire dimension correlation
(osBuild/model/agentVersion/imeVersion; hit set from a RuleResults
range scan intersected with the window sessions) reports both shares
with the "correlated, not necessarily causal" wording contract or
honestly "no clear dimension concentration". Radar runs in the daily
maintenance pass after the rule-stats aggregation, anchored on
yesterday (whole days only).
Surfaces: regressions[] in the rule-stats response (MCP get_rule_stats
inherits it), tenant bell deep-linking to the rule card, and the
analyze-rules page gains a densified 30-day fire sparkline plus a
regression badge while an episode is active. OKF doc
backend/rule-regression-radar.md + index/log.
Backend 3474/3474 (27 new: Wilson vectors, every suppression branch,
window boundary dates, re-arm matrix, dimension gates, tracker
round-trips incl. tri-states, keyspace dedup/prune, wording pins),
web tsc + vitest 627/627, MCP tsc + 455 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three read routes over the PR4 data: metrics/device-history
(MemberRead + tenant query param - one route serves the web banner AND
MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner
fallback; history:null is a normal outcome), metrics/device-journeys
and global/metrics/device-journeys (days selector honored because FTR
counts are additive - window rate = ratio of summed daily rows; the
tenant-less global variant serves the "global" partition and discloses
repeatDevices:null). Attempt numbers are server-computed
(DeviceJourneyCalculator.ComputeAttemptNumber: chain position within
the journey; live sessions via a virtual non-successful attempt so the
redeploy and 30d-gap rules apply) - no consumer re-derives journey
semantics. Repeat-devices violator list: last journey >=2 attempts with
newest terminal ref in-window, attempts desc then recency, cap 10,
failure reason from the newest failed attempt (bounded point-reads).
Web: DeviceHistoryBanner on session detail ("Attempt N for this device
- View history", expandable terminal chain, durations strictly the
authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the
Fleet Health "First-time-right" section (rate gated at >=20 completed
journeys with disclosed n, junk-serial exclusion note, Monday-UTC
weekly trend, attempt histogram, repeat devices) following the 7/30/90
selector. MCP: get_device_history (serial mode / fleet FTR mode,
role-aware routing, all roles - GA_FULL privilege snapshot extended
deliberately). Docs: OKF backend/device-journeys.md + index/log.
Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries
and live sessions, additive sums, days clamping, repeat-device
gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device-key normalization (trim+casefold, junk-serial exclusion incl. the
agent's "Unknown" WMI sentinel), pure journey grouping (terminal set
Succeeded/Failed/Incomplete; journey ends at first success; >30d gap from
the previous attempt's CompletedAt starts a new journey; WG Pending stays
an open session, never an attempt), and the DeviceHistories table
(PK tenant, RK encoded normalized serial): terminal-only session-ref
chains capped at 20 with derived journey counts, written inline at the
session-terminal seam (all three terminal statuses, DeletionState-guarded)
and healed by a rolling 30d maintenance sweep. The sweep prunes chain refs
of deleted sessions tombstone-driven (SessionTombstones cover cascade AND
retention deletions), deletes emptied rows, and recomputes daily FTR
aggregates (DeviceJourneyAggregates, PK tenant/"global", RK date):
completed journeys, first-time-right count, attempt histogram and
junk-serial disclosure — additive counts, bucketed on the completing
success session's StartedAt date. Chain refs carry the authoritative
DurationSeconds verbatim (never CompletedAt-StartedAt) plus an
adminMarked flag. Both tables registered in Constants.All + offboarding
TenantPartitionTables; aggregates under the 180d retention block;
deliberately NO per-session deletion-manifest step for DeviceHistories.
Backend 3433/3433 (51 new tests: junk/normalization vectors, terminal
set, exact 30d gap boundaries, WG one-row-one-attempt, redeploy split,
deletion pruning, entity round-trips incl. row-key escaping, FTR
aggregation core), Agent 2044+493 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Insights expansion PR3 (tasks/insights-expansion-spec.md §F1 "Surfaces").
Completes F1: schema (PR1) -> persistence/aggregation (PR2) -> surfaces.
- Rolling range rows: the sweep now also writes "rolling30|{class}"
aggregate rows — range statistics over the window's actual sessions. A
median of per-day medians is not the range median, so the fleet panel
reads these and is deliberately fixed to "last 30 days"; the daily rows
stay in the payload for trends/MCP.
- Routes (all registered in EndpointAccessPolicyCatalog):
GET sessions/{sessionId}/time-attribution (MemberRead + tenant query
param, GA fallback via FindSessionTenantId; breakdown=null is SUCCESS —
a missing lane is a normal state, not an error), GET
metrics/time-attribution (MemberRead) and GET
global/metrics/time-attribution (GlobalReadOrAdmin + tenantId filter),
both returning { windowDays, classes (rolling), daily }.
- Web: TimeAttributionLane under the session-detail phase timeline —
proportional stacked bar of segment totals incl. the explicit
unattributed remainder (bar always sums to the session's authoritative
duration), ESP-blocking apps with critical-path occupancy, reboot badge
(cross-cutting, never a bar slice), quality-flag chips; fetched
fail-soft for terminal sessions only. Fleet Health gains the "Time
attribution" section: per-class stacked medians gated at n>=20
("insufficient data (n=...)" below), flagged/missing exclusions
disclosed, and a top-blocking-apps table with class-scoped rows and
"up to X (p75 Y)" what-if wording. Deliberate deviation: the spec's
per-app trend column is omitted — per-day app rows are too sparse under
the daily n>=5 gate; revisit once rolling history exists.
- MCP: get_time_attribution (one tool, session + fleet mode, role-aware
routing like get_app_install_metrics, registered for all roles) — the
privilege-leak-guard catalog snapshot extended deliberately.
- Docs: OKF concept doc backend/time-attribution.md (invariant, windows,
segment mapping, positive-evidence blocking, Q7 reboot spans,
persistence + surfaces) registered in index.md + log.md.
Backend 3382/3382 (+1 rolling-aggregate test), web tsc clean + vitest
627/627, MCP tsc clean + 455 passed. Post-deploy: web deploy +
MCP redeploy (new tool + docs corpus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Insights expansion PR2 (tasks/insights-expansion-spec.md §F1 "Data & compute
changes"):
- SessionTimeBreakdowns table (PK=TenantId, RK=SessionId): span/reboot/
blocking-app JSON columns, QualityFlags, AttributionVersion,
EspAppsOccupancySeconds kept tri-state (absent column = unknown, never 0).
Written once at the terminal single-writer seam (TableSessionRepository,
next to the counter reconcile + EspBlocking resolution; Succeeded/Failed
only — Incomplete deliberately stores no duration). Deleted with its
session: new deletion-manifest step 16 (PkRkExact; tombstone moves to 19)
plus tenant-offboarding partition wipe — no separate age sweep.
- Self-healing 30d maintenance sweep instead of a one-off backfill (rides
the existing 2h tick + manual parity): backfills pre-deploy sessions,
heals inline-compute misses, folds late-terminating sessions into their
StartedAt-date aggregates, and enforces AttributionVersion purity — a
stale-version breakdown whose events aged out counts as missing rather
than mixing algorithm semantics (truthfulness rule 8). Skips sessions
with a deletion cascade in flight.
- TimeAttributionAggregates table (PK=TenantId or "global",
RK="{date}|{class}"): per enrollment class (whiteglove /
device_preparation / self_deploying / user_driven — never mixed),
median/p75/p90 per segment over the fixed six-segment stack (incl.
unattributed; absent segment = honest 0), clean/flagged/missing counts
disclosed (rule 7), rows written below the n>=20 UI gate on purpose
(the UI needs the n — rule 4). 180d retention alongside UsageMetrics.
- What-if bound: WhatIfSavingSeconds implements the spec formula
max(0, cpEnd - cpEndWithoutX) including the idle gap before X (the ESP
waits for X to start, too); sole-interval fallback = own start. Per-app
aggregate rows gate at >=5 sessions, top 20 by median interval, carry
median/p75 of interval and saving ("up to" wording is the consumer
contract).
Backend 3381/3381 (13 new tests: entity round-trips incl. tri-state,
aggregation bucketing/disclosure/gates, what-if vectors, class precedence,
manifest step-count pins 17/19), solution build clean, web untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Insights expansion PR1 (tasks/insights-expansion-spec.md §F1, audit §0.5):
- AppInstallSummary + AppId / EspBlocking (bool?, tri-state) / AppIdCollision:
sentinel-gated columns + mapper + both projections. appId adopted from app
events (first-seen wins); a second appId under the same display name flags
the name-keyed row as a collision (audit Q3) — in-batch and cross-batch
(sticky in the reconcile). Collision rows leave every per-app fleet
aggregate (app metrics payload, apps list, app analytics) with a disclosed
exclusion count; the fleet DO rollup and per-app session drill-down keep
them.
- EspBlocking resolution at the session-terminal single-writer seam
(TableSessionRepository.UpdateSessionStatusAsync, next to the counter
reconcile — covers ingest, admin marks, maintenance sweep, rule engine):
joins the session's app rows against the LATEST list-carrying
esp_config_detected emission as a positive-evidence set (audit Q2) —
listed => true, absent => stays unknown, never false. EspBlockingSets
parser handles truncation via the uncapped espTracked*Count fields.
- TimeAttributionCalculator (pure, AttributionVersion 1) + SessionTimeBreakdown
models: exact partition of the session's authoritative DurationSeconds into
device_prep / esp_apps / identity_hello / user_esp / desktop_handoff spans
plus an explicit unattributed remainder — invariant sum(spans)+unattributed
== wall clock enforced in every test. WhiteGlove = two observation windows
in ONE session (part1 anchored on whiteglove_part1_complete, pause never
attributed). Per-app blocking intervals from EVENT timestamps (first start
-> LAST terminal, audit Q1 retry freeze), occupancy = merged union clipped
to esp_apps. Reboot spans from event gaps around lastBootUtc (audit Q7),
deduped per boot. QualityFlags: ClockSkewDropped, PartialObservation,
BlockingSetUnknown, BlockingSetTruncated, WhiteGloveAnchorsIncomplete.
Persistence (SessionTimeBreakdowns) and surfaces follow in PR2/PR3.
Backend 3368/3368 (35 new tests: 19 calculator golden fixtures + 16
identity/blocking/aggregate-exclusion), solution build clean, web untouched
(new response fields are additive).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Source-data audit (tasks/insights-expansion-spec.md §0.5) verified against
production: 17.9% of Succeeded AppInstallSummaries rows carry no duration —
78% are genuine skips (app_install_completed with payload state=Skipped,
"Update for X" WinGet policies not applicable) whose state the ingest
discarded, the rest are real installs whose start the agent never observed
(attach window). Averaging them as 0s understated avg install duration by
18% and distorted the slowest-apps ranking.
- AppInstallSummary.TerminalState (Installed/Skipped/Postponed/Error) from
the closing event's payload state; sentinel-gated column + mapper +
AppsDashboardProjection. Status stays "Succeeded" for compatibility.
- MetricsMath.IsSkipTerminalState/HasMeasuredDuration: durations computed
over measured rows only; skips leave the failure/success rate (decision
2026-07-26 — a skip is not an install attempt); per-app skipped/unmeasured
counts + totals exposed; slowest ranking gates on measured installs.
Applied consistently: app metrics payload, apps dashboard (list/analytics/
time series/trend/versions/device models), SLA weekly/snapshot/breach.
- StoreAppInstallSummaryAsync: reconcile extracted to internal static
ReconcileAppInstallSummaryWithExisting (test seam). Fixes Q4 (terminal
batch before started batch left duration permanently unset) and hardens
terminal status against late/replayed InProgress batches.
- Web: measured-only hints on app detail cards, skip suffix in apps list,
fleet-health "measured installs" caption.
Backend 3333/3333 (20 new tests), web tsc clean, vitest 627/627.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "Collect Logs" feature (b382e567) added a second opt-in path into hosted
diagnostics upload — the quick-config dialog on a session — and a new collection
trigger, neither of which the customer-facing pages described. The trust FAQ still
said hosted upload is enabled "in the settings UI", and the dialog itself named
hosted storage without stating that packages leave the tenant boundary, so the
"clearly marked disclosure" promise was not met on that path.
- CollectLogsButton: marked data-boundary disclosure in the quick-config dialog
(leaves your Azure tenant, per-tenant isolation, blob-scoped write-only token,
removal per Data Retention Days)
- privacy + about pages: both opt-in paths named; on-demand collection described
(Admin/Operator of the own tenant, same content as the automatic package,
delivered with the agent's next check-in, visible on the session timeline)
- docs repo counterpart in trust/security-faq.md (separate commit)
Distress-bell dedup: the TpmPssUnsupported one-shot bell had key-shape tests only.
New HardwareRejectionNotificationTrackerDedupTests pins the 409 dedup branch, the
fail-soft branch, the blank-input short-circuit, the disjoint hardware/tpmpss key
spaces, and the retention sweep — which also prunes tpmpss rows and thereby re-arms
that device's bell, now stated in the interface and implementation docs.
Backend tests 3313/3313, tsc clean, web vitest app/sessions 60/60.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rule list cards (analyze rules, gather rules, IME log patterns) used a
single-line flex header where every badge was flex-shrink-0 and only the
title could shrink — on phone widths the title truncated to a few
letters. The header now wraps: on mobile the title drops onto its own
full-width row (order-last + w-full) under the badge row and may wrap;
on >=sm the layout is unchanged (title inline, truncated).
PhaseTimeline squeezed up to 8 flex-1 columns with 48px circles and
whitespace-nowrap labels into the viewport, so circles and label text
overlapped completely on mobile. The timeline now has a minimum width
of 80px per phase inside an overflow-x-auto container (swipe on phone,
no scrollbar on desktop) and labels wrap inside their own column
instead of overlaying neighbours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dark mode: globals.css was missing the entire slate family (the
"Incomplete" status filter chip rendered white-on-white) and all light
hover tints outside gray/indigo/cyan (hover:bg-purple-50 in the tenant
suggestion dropdown flashed near-white under light text). Added slate
bg/text/border mappings mirroring gray, plus dark equivalents for every
hover:bg-*-50/100/200 class in use (blue, purple, red, amber, yellow,
green, orange, sky, teal, emerald, slate, gray-200/300).
Mobile: ScriptExecutions rows were single-line flex with flex-shrink-0
right clusters, so badges and status text overlapped/overflowed on
narrow screens. Header counters, cycle card headers and item rows now
flex-wrap with gap spacing instead of space-x.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Global Admin editing a gather/analyze rule while viewing a FOREIGN
tenant (override) got a green "saved" banner, but the write landed in
the GA's OWN home tenant: the JWT-scoped rules/{type}/{ruleId} routes
resolve the tenant from the caller's token, and no cross-tenant write
route existed (reads had /api/global/rules/*, writes did not). The
reload re-read the foreign tenant and showed the unchanged rule.
Fix: symmetric cross-tenant WRITE routes mirroring the global reads.
Backend:
- GlobalRulesFunction: PUT/DELETE global/rules/gather/{ruleId} and
global/rules/analyze/{ruleId}; tenant from ?tenantId= (400 if
missing), same 1 MB guard + ValidateScopeAndEmitMode as the JWT
variants, same service calls (toggle-partial/built-in-state/full-edit
semantics are tenant-parameterized and identical).
- Catalog: 4 entries GlobalAdminOnly + TenantScoping.QueryParam — a
write tier, so the delegated-read rescue never fires; only a platform
admin may mutate another tenant's rules. No global CREATE route:
create stays JWT-only and the portal hides Create in override.
Web:
- api.rules.globalGatherRule/globalAnalyzeRule helpers; both rule pages
route save/toggle/mark-failed/notify/delete through a
*RuleWriteUrl(ruleId) switch on isGlobalOverride.
- Analyze template-enable (a create) is blocked in override with a
clear message instead of silently writing to the wrong tenant.
Tests: CrossTenantAccessTests guards the 4 routes are GA-only +
QueryParam and that a GA reaches the named target tenant. Backend
3295/3295, web vitest 627/627, tsc clean.
Note: the old bug planted orphan copies of foreign rules into the GA's
home tenant on each attempted cross-tenant edit; those are not
auto-cleaned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops
the client cert on Win11 25H2+, agent can never authenticate) were operator-
only, yet the remediation is tenant-side: TPM firmware update or device
replacement.
- New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial,
mirroring audit/device-not-registered
- Bell notification "tpm_pss_unsupported" (Admin audience) once per
(tenant, serial); dedup via the existing rejection tracker table with a
disjoint "tpmpss|{serial}" row-key space; no webhook by design
- Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist
section; renders only when a device is affected (no permanent card)
No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session 8e4cc4ae: two "Collect Logs" round trips completed on the agent (both
ZIPs landed in hosted storage) but the portal never flipped to Download and
the button hung at "Collecting..." — the ServerActionDispatcher confirms an
on-demand upload only via server_action_executed(actionType=request_diagnostics,
blobName=...) and emits NO diagnostics_uploaded (only the terminal upload path
does), so UpdateSessionDiagnosticsBlobAsync never ran.
The ingest classification now treats such an executed event as the upload
confirmation (a real diagnostics_uploaded in the same batch still wins — it
carries destination). Because the executed event has no destination field,
InferDiagnosticsDestination derives "Hosted" from the backend-built
"{tenantId}/{filename}" blob-name shape (CustomerSas blobs are bare
filenames); without it the download route would look in the customer's
container for a hosted blob.
Backend-only — works with the already-deployed agent fleet, no agent release.
Backend suite 3274/3274.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operators previously saw editable settings forms bound to hardcoded defaults
whose Save always 403'd. Now they see the tenant's real configuration
read-only, with no save affordances at all.
Backend:
- GET config/{tenantId}: TenantAdminOrGlobalReader -> MemberRead. Safe because
the handler redacts by default (CanViewSecrets: only GA or own-tenant Admin
see secrets); Operators/Viewers receive RedactedCopyForReader().
- POST diagnostics/files: TenantAdminOrGA -> TenantAdminOrOperator (submitting
logs is a troubleshooting action; Viewer and read-only GlobalReader excluded).
- Tests: policy pins updated; new middleware tests (own-tenant Operator allowed
+ redacted, cross-tenant Operator 403); CanViewSecrets theory for
Operator/Viewer.
Web:
- TenantConfigContext loads the full config for all settings users (403
fallback to feature-flags minimal config for deploy-order safety) and
exposes canEditConfig; saveConfiguration guards on it.
- New ReadOnlyFieldset wraps section content (native fieldset disable, values
stay copyable); SaveResetBar is not rendered at all for read-only viewers.
Applied to Contact, SLA Targets, Hardware Whitelist (+ rejection-insights
toggle), Notifications, Agent Settings, Agent Analyzers, Diagnostics, Data
Management.
- Hidden entirely from Operators (in-page admin-only card + sidebar sub-item
gate): Autopilot Validation, Access Management, Offboarding, Unrestricted
Mode. GlobalReader sidebar parity kept via hasGlobalScope.
Tests: backend 3257/3257, web vitest 627/627, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the queue-on-session/deliver-on-ingest channel, the
TenantAdminOrOperator tier with in-function per-type re-gating, the portal
Collect Logs flow (quick-config orders rotate_config before
request_diagnostics), sequence-based progress matching, and the hosted-prefix
sweep in the offboarding cascade. Registered in index.md, logged in log.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Queues a request_diagnostics ServerAction (delivered with the agent's next
ingest response) and tracks progress live: server_action_received flips the
button to "Collecting…", the DiagnosticsBlobName SignalR delta completes it
(Download Diagnostics appears), server_action_failed or a 3-min quiet timeout
surface as toasts.
- Button is ALWAYS rendered — disabled states carry tooltips (inactive
session, cross-tenant view, missing role, diagnostics not configured) as a
deliberate nudge towards enabling diagnostics upload.
- Tenant Admin on an unconfigured tenant gets a quick-config dialog: switches
the tenant to Hosted + OnFailure (GET config -> patch two fields -> PUT
verbatim), then queues rotate_config BEFORE request_diagnostics so the agent
collects against the fresh config. Operators/Viewers never see the dialog.
- Operator may collect when configured (new TenantAdminOrOperator backend tier).
- useSessionTenantConfig picks up diagnosticsUploadConfigured from the
member-readable feature-flags endpoint.
- Progress matching is sequence-based (backend-assigned) so agent clock skew
and at-least-once action redelivery cannot produce false matches.
Pure state logic in collectLogsLogic.ts with vitest matrix (21 tests).
tsc clean; web suite 627/627.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
blob) so on-demand packages overwritten on the Sessions row by the terminal
package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
tenant-shared; operators would clear admin-tier notifications they cannot see.
Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependabot alert #74: PostCSS <= 8.5.11 allows arbitrary file read /
information disclosure via an attacker-controlled sourceMappingURL in CSS
comments. Raise the floor (devDependency + override) from ^8.5.10 to
^8.5.12; the lockfile now resolves postcss 8.5.22 (its nanoid dep moves
3.3.11 -> 3.3.16 with it). npm audit reports 0 vulnerabilities.
Next.js pins postcss 8.4.31 transitively, but the existing root override
collapses the tree to a single postcss node, so the bump covers it too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Customer follow-up corrected the field-case mechanics: TrackingPoliciesCreated=1
WAS set under the foreign ConfigMgr provider - the ESP ignores a foreign
provider's completion values and waits on the Intune-registered "Sidecar"
provider by name (renaming the key unblocks instantly without creating any
tracking entries). The completeness-keyed detector would have missed exactly
the incident it was built for.
- Arm 2: >=1 Setup\Apps provider registered without Sidecar, continuously for
the 15-min dwell -> reason=sidecar_provider_missing; clock resets when
Sidecar registers later (legitimate startup ordering); DevicePreparation-only
snapshots don't activate it
- Arm 1 (continuously-incomplete providers) unchanged -> reason=provider_incomplete;
sidecar-missing wins when both arms cross in one round (single event)
- Cross-restart fingerprint gains a "sidecarMissing" sentinel; payload adds
reason + sidecarMissingForMinutes
- Regression test: ConfigMgr complete + Sidecar absent -> fires (previously
classified healthy); probe/doc headers and OKF doc corrected, web event-type
description updated
Evidence (research session c7e3db08): Intune's ESP-policy WAP payload always
registers Sidecar (ConfigMgr only via co-management settings policy, per
Niehaus' documented payload); Microsoft's ESP troubleshooting models app
tracking exclusively through the Sidecar subkey.
Agent tests 2043/2043, web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Azure/static-web-apps-deploy@v1 has no `skip_deploy_on_missing_secrets`
input (confirmed against its action.yml), so passing it did nothing but
emit an "Unexpected input(s)" warning — the intended "skip green on
Dependabot PRs" behaviour was never actually in effect.
Replace it with the documented mechanism: surface the deploy token as a
job-level env var and gate the deploy/close step with `if: env.DEPLOY_TOKEN
!= ''` (secrets cannot be referenced directly in an `if:`). On Dependabot
PRs the token is absent, so the step skips and the job stays green; real
deploys on main and internal PRs still run. Applied to both jobs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BuildFleetFailureReasons truncated each reason to a 50-char prefix + "..."
and used that as both the grouping key AND the display value. The full text
never reached the UI, so its tooltip/tap-to-expand had nothing to reveal and
near-identical rows were indistinguishable past the cut.
Group by the 50-char prefix as before (variants still collapse into one row),
but carry the longest full reason as the display value. The UI truncates and
expands it on demand, so the complete text is now available end-to-end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior ">=22" made Oryx build with the highest available Node (24.13),
which the Static Web Apps runtime rejects: "Node version 24.13.0 is not
supported. Please use one of the following versions 18, 20, 22." Pin to
22.x so Oryx selects the SWA-supported major. CI setup-node '22' already
resolves to latest 22.x, so build and deploy now agree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Node 20 reached EOL (2026-04-30); Node 22 is the current maintenance LTS
and already the runtime the MCP container ships (node:22-slim). Straighten
the version skew so per-component CI major == deploy runtime == engines floor:
- ci.yml: web, mcp and guardrails setup-node 20 -> 22
- combine-rules.yml: setup-node 20 -> 22
- mcp package.json: add engines.node ">=22" (matches Dockerfile)
- web package.json: add engines.node ">=22" (first explicit pin; SWA/Oryx
no longer picks a Node version implicitly)
engine-strict stays off (no .npmrc), so the pins are advisory and cannot
hard-break local installs on other Node versions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The expanded state used line-clamp-2, which re-truncated long values with an
ellipsis after two lines — unreadable on touch where the hover tooltip is
unavailable. Expand now wraps the entire string over as many lines as needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add reusable TruncatedLabel component: a single-line label that truncates
with an ellipsis, shows the full text as a native hover tooltip, and on
click/Enter/Space expands to two wrapped lines (touch-friendly). An
interactive={false} mode provides tooltip-only behaviour for use inside
already-clickable rows/links where an inner onClick would hijack navigation.
Apply it consistently to the long, dynamic label columns across:
- fleet-health: failure reasons, model + app ranked lists
- sla: top failing apps, violators failure reason
- geographic-performance: fastest/slowest location cards
- vulnerability exposure panel: CVE sample software
- agent metrics: top exception types
- progress + download/install: app-name labels
- admin software tabs: software names
- session reports (comment/adminNote) + ops events (message): tooltip-only
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second half of the terminal-only rate convention (follows 1cfc32c9, which
converted the enrollment success rates): app-install failureRate divided
by ALL install rows including "InProgress" ones — installs still running,
or orphaned when a session died mid-install — diluting every failure rate
customers see on the Apps dashboard and Fleet Health.
- New MetricsMath.TerminalFailureRatePct (Failed / (Failed + Succeeded),
0 when nothing finished) shared by both aggregation sites
- MetricsMath.BuildAppMetricsPayload: per-app failureRate terminal-based
(Fleet Health "Top Failing Apps"; subtitle now "% of N finished")
- AppsAnalyticsHelper: list, analytics summary, versionBreakdown,
timeSeries and deviceModelBreakdown rates terminal-based; the
duplicated trend block is deduplicated into ComputeFailureTrend, whose
rates and >= 5-sample gate now run on finished installs per half; the
device-model >= 5 sample floor likewise counts finished installs
- Web: InstallsTab "Avg Failure Rate" card computed over summed finished
installs instead of totalInstalls
Tests: new AppsAnalyticsFailureRateTests (list/trend/analytics/model
cases incl. in-flight dilution and gate regressions) and a terminal-rate
case in MetricsMathAppPayloadTests. Backend 3223/3223, tsc clean,
vitest 602/602.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customer feedback: the geographic performance pane showed a poor success
rate for a site mid-rollout because in-progress/pending sessions counted
against the rate. Success rate is an outcome quota — align Geo and Fleet
Health with the SLA convention already used by dashboard stats, usage
snapshots and the fleet rollup: Succeeded / (Succeeded + Failed).
Incomplete (terminal, non-failure) stays excluded.
- Geo: per-location SuccessRate over finished sessions; table badge and
map tooltip render an em dash instead of a red 0% when nothing has
finished yet
- Fleet Health: Stats.SuccessRate terminal-based; card shows "X of Y
finished enrollments" and a neutral em dash state; per-model health
gets a Failed count (additive wire field) so the client rate excludes
in-flight devices; TopFailingModels FailureRate now failed/finished
- Tests: FleetHealthPayloadTests flipped to the new convention plus
zero-finished, model-failed and in-flight-dilution cases; new
GeographicMetricsSuccessRateTests pin the geo semantics
Backend 3216/3216, web tsc clean, vitest 602/602.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI run 30047512186 failed Worker_drops_envelope_missing_required_fields_
without_invoking_handler: RunForAsync starts the BackgroundService poll loop
and waits a fixed 500ms wall-clock window — on a loaded CI runner (xUnit runs
test classes in parallel) the loop may not have processed the message before
the positive assertions (DeleteMessageAsync at least once) run. All ten
positive tests in the file shared that latent race; this run it hit the
malformed-envelope drop test.
Fix mirrors the watchdog event-gate de-flake: new Harness.RunUntilAsync
(condition, 10s ceiling, 25ms poll) runs the worker until the awaited side
effect is observable, then stops. Gates: ops-event recorded (last step of the
poison path, so send+delete+CAS are already done), main-queue delete for the
drop tests, >=3 visibility heartbeats for the heartbeat test. The kill-switch
test keeps the fixed window — a Times.Never expectation has no event to wait
for. OpsEventCalls capture is now lock-guarded so the condition poll can read
it while the worker thread appends.
16/16 worker tests green across 4 consecutive runs; full backend suite
3211/3211.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part 2 of the eaf3d8c4 fix (follows 088c3b04). In that session 147 of 157 app
events were replayed from a previous enrollment's IME log — all 10 "installed
apps" were phantoms, polluting the timeline, AppInstallSummaries, MCP counts,
the app_tracking_summary header, culprit lists and final-status.json.
Rename (nothing deployed yet): historic_script_replay_detected →
historic_ime_replay_detected; SuppressIfHistoricReplay / IsHistoricImeReplay.
Agent:
- EmitAppState suppresses stale-replay transitions BEFORE the sub-phase
declaration, timing bookkeeping and terminal-signal dedup — covers the
informational event, the AppInstallCompleted/Failed DecisionSignal, the
shadow download_progress and the summary snapshot. Behavior note: phantom
terminal signals can no longer spuriously re-arm the AdvisoryCompletion
window (app signals are otherwise observation-only — verified no completion
gate reads them). EmitDoTelemetry gets the same guard.
- Tracker-level guard at the HandlePatternMatch choke point skips the 23
app-mutating actions (incl. espphasedetected — a stale AccountSetup line
would make fresh DeviceSetup lines bounce as "backward") for source lines
>24h stale, so replayed apps never enter PackageStates, persistence,
app_tracking_summary, culprit lists or final-status. New internal
UtcNowProvider seam (no ctor change), threshold single-sourced as
ImeLogTracker.HistoricReplayThreshold.
- SimulationMode now bypasses all replay suppression — 088c3b04 had broken
the --replay-log-dir dev tool, which feeds historic lines on purpose.
Backend: IsHistoricImeReplay guard in AggregateAppInstallEvent keeps replayed
events out of AppInstallSummaries (SLA/dashboards/metrics inherit).
Web: generic lib/historicReplay.ts partition (scriptExecutions becomes a
wrapper); InstallProgress hides replayed installs with a muted note;
DownloadProgress filters silently.
MCP: get_session_summary filters replayed events — cleans appInstalls stats,
errorCount (replayed app_install_failed are Error-severity) and keyEvents.
Tests: +13 agent (app suppression, tracker guard), +1 backend, +7 web,
+6 MCP. Agent 2039, backend 3211, web 602, MCP 455 all green; drift test
validates the rename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session eaf3d8c4: a previous enrollment's IME logs survived on disk (dated
2026-07-16, 7 days old) and the agent replayed them at startup — 156 phantom
script_completed events in a 7-minute session, each showing ~170h durations
(clock-clamped completion minus RAW stale start), plus an immediateUpload
flood (3x ingress_backpressure) and RemediationScriptCount inflated to 156.
Agent:
- Script events whose source line is >24h stale are suppressed entirely; a
one-shot historic_script_replay_detected Info event (new EventType) dates
the replayed window via earliestRejectedSourceTimestamp. Future-skew
rejections and clock-only fallbacks still emit.
- durationSeconds now uses a timeline-consistent pair: on rejected-timestamp
emissions the raw source completion pairs with the raw start (true runtime
instead of cross-timeline garbage). 24h plausibility backstop omits
implausible values; same consistency for script_timeout_suspected.
- Stale pending platform-script slots (>24h older than the current start
line) are discarded so an old orphaned start can't pair with a fresh run
(extracted HandlePlatformScriptStarted as internal test seam).
Web: partitionHistoricScriptEvents hides legacy-agent replay events (muted
"N historic executions hidden" note) and the reducer blanks >24h durations —
covers sessions recorded before the agent fix rolls out.
Backend: IsHistoricScriptReplay guard keeps replayed events from inflating
Platform-/RemediationScriptCount during the rollout window.
Registered historic_script_replay_detected in Constants.EventTypes, MCP
resource-catalog (script_events) + KEY_EVENT_TYPES, gather-rules eventTypes.
Tests: 8 agent (suppression/one-shot/raw-pair/backstop/slot-hardening),
8 web (partition/blanking), 10 backend (predicate). Agent 2026, backend
3210, web 595, MCP 449 all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The straggler integration test copied powershell.exe under a synthetic
agent name and launched it — textbook MITRE T1036.003 ("Rename Legitimate
Utilities"). Defender for Endpoint flagged the run ("System executable
renamed and launched", initiating process testhost.net48.exe) and ISOLATED
the dev machine.
Replaced with function shadowing: a harness script defines Get-Process /
Stop-Process stub functions (PowerShell resolves functions before cmdlets)
and dot-sources the cleanup script. The stubs report a live agent-named
straggler on every probe and record the kill, pinning the identical
contract — poll by NAME until the deadline, force-kill by NAME, then
delete — with zero process spawns, zero PE copies, nothing EDR-visible.
The production routine never renames or copies executables; the flagged
technique existed only in this test. Lesson recorded in tasks/lessons.md.
22/22 cleanup tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DeviceSetup/Apps failed, the user pressed "Try again" ~23 min later, apps
recovered to 10/10 and DeviceSetup resolved - yet the advisory_completion
window un-defanged the stale failure and failed the live enrollment,
because the advisory variant was exempt from all re-arm/rebase guards.
Engine fix (DecisionCore):
- New facts EspAdvisoryFailureResolvedUtc + EspAdvisoryFailureCategory
(state-borne; deadline payload would be lost on re-arm/rebase).
- TryResolveAdvisoryOnCategoryRecovery in both ProvisioningComplete
handlers: category match sets the fact + emits
esp_failure_advisory_resolved.
- Guards now (!hasAdvisoryAnchor || advisoryResolved): enforcement-progress
re-arm, hello-never-observed promote and the reboot rebase apply again
once the failure demonstrably un-happened; unresolved advisories keep
the legacy un-defang exactly.
- Truthful terminal reason esp_recovered_without_completion_evidence
(+ failureRecovered=true, LastFailureTrigger=DeadlineFired so the
likely-stuck app promotion stays off).
Story events (agent tracker):
- esp_failure_retry_detected: failed subcategory leaves "failed" after the
terminal fire - consistent with the user pressing "Try again".
- esp_failure_recovered: previously failed category reaches success.
- EspFailureDetectedEventArgs.LikelyCulpritApps (never-started first,
cap 3) rides the EspTerminalFailure payload so esp_failure_advisory /
enrollment_failed name the culprit app directly.
Surfacing: MCP resource-catalog, web timeline badges (Recovered /
User retry pills, culprit chip on advisory), gather-rule autocomplete,
ANALYZE-ESP-006 "ESP Failure Recovered After User Retry".
Tests: AdvisoryRecoveryTests (13, incl. the 527/611/635/671 field-ordinal
replay that no longer fails the session), EspFailureRecoveryStoryTests (5),
snapshot-builder guards 18 -> 20. All suites green (DecisionCore 493,
V2.Core 2018, Backend 3201, web vitest 589, MCP 449 incl. drift).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customer field case: pre-provisioned devices with the ConfigMgr client as a
blocking app occasionally park the user ESP at "Apps (Identifying)" for days.
Root cause (verified against the EnrollmentStatusTracking CSP doc): only a
"ConfigMgr" provider is registered under Device\Setup\Apps\PolicyProviders
(no "Sidecar" = IME) and TrackingPoliciesCreated never becomes 1 - a wait the
CSP defines WITHOUT any timeout. Renaming the key to Sidecar unblocks
immediately. Microsoft documents that ESP providers are not aware of each
other and that pre-provisioning is unsupported with co-management.
New always-on esp_policy_provider_stalled tripwire, generalized to any
provider in both kinds (Setup/Apps TrackingPoliciesCreated != 1,
DevicePreparation InstallationState not in {2,3}):
- EspPolicyProviderProbe: fail-soft registry read of the
EnrollmentStatusTracking mirror (device + user-SID scopes), defensive
value parsing (missing/unparseable = incomplete), ScopedOverride test seam.
Research and CSP contract quotes captured in the XML doc header.
- EspPolicyProviderStallDetector: pure EvaluateStallTransition on a 15-min
continuous-incompleteness dwell (resets on completion/disappearance;
absent key = normal early enrollment), one-shot per provider,
StartupEventGate fingerprint over the sorted stalled key set for
cross-restart dedup. Warning + ImmediateUpload via InformationalEventPost;
purely observational, no decision-engine coupling, no config toggle,
no ConfigVersion bump (disk_space_low precedent).
- EspPolicyProviderStallHost: 60-s wall-clock tick, deliberately outside the
StallProbe idle gate (idle clock resets on session activity) and the
idle-stopped periodic collectors.
- Registered in Constants.EventTypes, MCP resource-catalog (health_events),
web gather-rule autocomplete (stall category).
Guardrail companion: SOFTWARE\Microsoft\Windows\Autopilot joins the
gather-rule registry allowlist ("Autopilot / OOBE / Setup") so tenants can
build custom rules on the subtree (e.g. not_exists on the Sidecar provider
key); generated web mirror regenerated via combine.js; positive + sibling-
spoof guard tests added.
Docs: OKF concept doc agent/esp-policy-provider-stall.md (+ index/log).
Tests: 22 new detector tests (pure transition matrix + emission surface incl.
the ConfigMgr-only customer scenario and gate-across-restart dedup); agent
suite 2013/2013, web vitest 589/589 + tsc clean, MCP 449 passed incl.
event-types-drift.
Both the detector and the embedded allowlist take effect with the next agent
release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customer report (session fcb44595): dashboard said 115 min, session detail
said 5h 2m for the same pre-provisioned enrollment. The detail's per-block
duration calc took min/max over ALL event timestamps inside each
sequence-block, which two real-world timestamp shapes break:
- an app_install_started re-emitted by the ImeLogTracker after the Part-2
resume legitimately carries its historical Part-1 event time -> the User
Enrollment block stretched back across the sealed pause (56m -> 3h18m)
- a do_telemetry with a +1h source timestamp (IME log line with a wrong
timezone bias, just under the agent's 1h future-skew clamp) stretched
Pre-Provisioning (58m -> 1h44m)
Sum: exactly the "5h 2m" the customer saw.
Block spans are now anchored on the first/last event BY SEQUENCE (sequence
is the canonical order), with Part 1 starting at session.startedAt for
backend DurationSeconds parity. Extracted as pure helper
computeWhiteGloveDurations with tests modeled on the real session; replay
against the customer's 880 events yields 1h 54m == backend 6872s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:26:33 +02:00
1082 changed files with 75008 additions and 10439 deletions
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
## Private Preview
## Availability
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
description: Operator-set tenant opt-in that turns an immediate Device-phase ESP terminal failure on a Continue-Anyway-enabled profile into a bounded 60-minute observation; the DAD-validated real-user desktop plus the Hello gate detect the true end, and the session completes as Succeeded with an esp-soft-failure marker.
title: ESP Policy-Provider Stall Detection — the no-timeout Setup/Apps wait and the co-management Sidecar gap
description: Why the ESP's Apps wait is keyed to the Intune-registered "Sidecar" provider by name — a foreign provider (ConfigMgr) with TrackingPoliciesCreated=1 does not satisfy it — plus the classic never-completes stall, and how the always-on two-arm esp_policy_provider_stalled tripwire (reason=provider_incomplete | sidecar_provider_missing, 15-min wall-clock dwell, StartupEventGate cross-restart dedup) surfaces both.
* Windows Autopilot into co-management (requirements, provider-conflict warning, troubleshooting registry paths) — <https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment>
* Troubleshoot the ESP (models app tracking exclusively via the `Sidecar` subkey) — <https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/understand-troubleshoot-esp>
* Niehaus: the Intune ESP-policy WAP payload registering the provider nodes (`Sidecar` always, `ConfigMgr` only with a co-management settings policy) — <https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/>
* Niehaus: ESP tracks arbitrarily named providers when they are properly registered — <https://oofhours.com/2023/09/18/track-anything-using-esp/>
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
A gather rule that produces no timeline events is indistinguishable from a rule that
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
them were visible to the customer:
| # | Decision point | Where |
|---|---|---|
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
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.
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.
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them.
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.
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).
`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.
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
description: How the radar detects analyze rules whose hit rate suddenly rose (7-day window vs 28-day baseline, Wilson-separated), which suppressions keep it quiet, how alert episodes live in the notification tracker, and which surfaces show them.
description: The queue-on-session, deliver-on-ingest ServerAction channel (terminate_session / rotate_config / request_diagnostics), the operator-tier authorization split, the portal's Collect Logs flow including the admin quick-config path, and why the offboarding cascade sweeps the hosted diagnostics prefix.
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.
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.
description: How a terminal session's wall clock is partitioned into named segments with an explicit unattributed remainder, how ESP-blocking apps and reboots are attributed, and where breakdowns and fleet aggregates are computed, stored and served.
* [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.
* [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).
* [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.
* [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.
* [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.
* [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
* [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).
* [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
* [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.
* [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.
* [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.
* [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.
* [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.
* [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
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
# Web Portal
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
* **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).
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
## 2026-08-06
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
## 2026-08-05 (later)
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
## 2026-08-05
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
## 2026-08-04
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
## 2026-08-02
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
## 2026-07-29
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#:`BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts``/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
## 2026-07-27
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
## 2026-07-24
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
* **Creation**: Added `agent/secureboot-ca2023-detection.md` — ANALYZE-SEC-001 warned on devices whose firmware already carried the 2023 certificate, because the agent only read the `SecureBoot\Servicing\UEFICA2023Status` registry value, which mirrors the Windows Update rollout rather than the firmware (customer report). The agent now reads the UEFI `db`/`KEK` variables natively (`GetFirmwareEnvironmentVariableExW` with `SeSystemEnvironmentPrivilege` enabled on the process token — first privilege-adjustment code in the repo) and emits per-certificate booleans on `secureboot_status`. Suppression rides on a one-sided marker, `uefiCa2023FirmwareConfirmed`, emitted only on positive confirmation: the engine's `not_exists` precondition fails on any present value, so a two-sided boolean could not distinguish "confirmed" from "checked and missing", while the marker's absence keeps old agents and firmware-missing devices on the v2 path. Rule v3.0.0 is therefore safe to seed before the agent release.
@@ -42,6 +154,10 @@
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
## 2026-07-26
* **Creation**: Added `backend/time-attribution.md` — the F1 enrollment time attribution: exact-partition invariant against the session's authoritative DurationSeconds, two-window WhiteGlove model, segment mapping from phase-declaration events, positive-evidence ESP-blocking join with "up to" what-if bounds, reboot spans from event gaps around `lastBootUtc`, and the SessionTimeBreakdowns / TimeAttributionAggregates tables with their terminal-seam + maintenance-sweep compute paths and read surfaces (web lane, fleet panel, MCP `get_time_attribution`).
## 2026-07-17
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
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."
**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:
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**.
| `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) |
| 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): `evaluateOn: ["enrollment_end", "whiteglove_sealed"]` — technician gets the channel alert at the bench; Part-2 terminal pass finalizes or resolves the finding.
- 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).
# 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.
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,7 +27,7 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
| --- | --- | --- |
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
| `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 |
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.
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
@description('Entra ID client secret for OAuth token exchange')
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
paramoAuthStateSigningKeystring=''
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
paramtoolLoggingEnabledbool=false
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
"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.",
"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.",
"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.",
"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.",
"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",
"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",
"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."
"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",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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).",
"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.",
"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.",
"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"
"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).",
"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).",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
"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.1.0",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence":85,
"conditions":[
{
"signal":"advisory",
"source":"event_type",
"eventType":"esp_failure_advisory",
"operator":"exists",
"value":"",
"required":true
},
{
"signal":"recovered",
"source":"event_type",
"eventType":"esp_failure_recovered",
"operator":"exists",
"value":"",
"required":true
},
{
"signal":"retry",
"source":"event_type",
"eventType":"esp_failure_retry_detected",
"operator":"exists",
"value":"",
"required":false
},
{
"signal":"category",
"source":"event_type",
"eventType":"esp_failure_recovered",
"dataField":"category",
"operator":"exists",
"value":"",
"required":false
},
{
"signal":"failedSubcategory",
"source":"event_type",
"eventType":"esp_failure_recovered",
"dataField":"failedSubcategory",
"operator":"exists",
"value":"",
"required":false
},
{
"signal":"likelyCulpritApps",
"source":"event_type",
"eventType":"esp_failure_advisory",
"dataField":"likelyCulpritApps",
"operator":"exists",
"value":"",
"required":false
},
{
"signal":"minutesSinceFailure",
"source":"event_type",
"eventType":"esp_failure_recovered",
"dataField":"minutesSinceFailure",
"operator":"exists",
"value":"",
"required":false
}
],
"confidenceFactors":[
{
"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":[
{
"title":"Make the first install attempt stick",
"steps":[
"Check the likely culprit app(s) (`{{likelyCulpritApps}}`) in Intune: an app that fails on the first ESP pass but succeeds on retry usually has a dependency/ordering or detection-timing problem.",
"If the app shows 'never started installing' in esp_failure_settle_started, verify its assignment and applicability rules — ESP counted it as required, but IME never processed it.",
"If the pattern repeats across the tenant, consider removing the app from the ESP blocking list until the first-attempt install is reliable — users are seeing an avoidable failure screen.",
"No device-side action is needed for this session: the failure recovered and the enrollment continued."
"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.",
"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.",
"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.",
"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.",
"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."
"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)",
"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.",
"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`.",
"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.",
"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}}`",
"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).",
"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.",
"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.",
"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.",
"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.",
"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.1.0",
"author":"Autopilot Monitor",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"trigger":"single",
"baseConfidence":70,
"preconditions":[
{
"source":"event_data",
"eventType":"secureboot_status",
"dataField":"note",
"operator":"not_exists",
"value":"",
"description":"Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
}
],
"conditions":[
{
"signal":"secureboot_disabled",
"source":"event_data",
"eventType":"secureboot_status",
"dataField":"uefiSecureBootEnabled",
"operator":"equals",
"value":"false",
"required":true
},
{
"signal":"physical_device",
"source":"event_data",
"eventType":"hardware_spec",
"dataField":"isVirtualMachine",
"operator":"equals",
"value":"false",
"required":false
}
],
"confidenceFactors":[
{
"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.",
"remediation":[
{
"title":"Enable Secure Boot in UEFI firmware",
"steps":[
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
]
},
{
"title":"Check compliance impact",
"steps":[
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
"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.",
"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.",
"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.",
"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). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
"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.",
"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).",
"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.",
"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.",
"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.",
"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.",
"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.",
"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).",
"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.",
"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.",
"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.",
"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.",
"parameters":{
"checkTo":"true"
},
"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.",
"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.",
"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.",
"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.",
"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
}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.