The automated security review of the previous commit found that a publisher pin alone
would run any validly signed script of ours placed at the copy's path, such as an older
loader without the held handle. The build now bakes the SHA-256 of the signed loader into
the action (wix build -d LoaderSha256=...; the package refuses to build without it), and
the action compares the bytes it read against it before the signature check.
- Package.wxs: hash check, preprocessor guard, comment
- build-msi.yml: the build step hashes the signed loader and passes it to wix
- Bootstrap.Integration.Tests.ps1: the harness substitutes the hash of the loader it serves;
a copy that is not the embedded loader, however validly signed, runs nothing
- BootstrapContractTests: the hash condition, the guard and the build step are pinned
- internal: D-384, footprint doc and plan updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.
- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
deferred SYSTEM action, started in System32, reads the copy once, checks our signature
and the publisher pin on those bytes and runs those bytes from memory, so a file a user
planted at that path leads to nothing and no handle keeps the installer from removing
the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
agent-present branch (install and skip clear the base already) and an agent folder that
is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The check from d4e67cdb now runs only for tenants that enable it, like the
RealmJoin Watcher: TenantConfiguration.EnableConfigMgrWatcher feeds
AnalyzerConfiguration.EnableConfigMgrWatcher (default off, ConfigVersion
42). With the setting off, DefaultComponentFactory does not create the
ConfigMgrClientHost, so other tenants' devices run no check at all.
- Portal: toggle "ConfigMgr Watcher" in Agent Settings, section field map,
tenant config report.
- Privacy page: the check is named as optional.
- Shared manifest and the web and MCP wire types regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The V1 agent reported whether the Configuration Manager client is
installed; the check was lost when the V2 agent replaced the V1 enrollment
tracker. ConfigMgrClientProbe reads what V1 read (the CCM key, CCMSetup
LastValidVersion, the assigned site code, the %windir%\CCM folder, the
CcmExec service state) and keeps the V1 confidence score.
ConfigMgrClientDetector, polled by ConfigMgrClientHost every minute, emits
configmgr_client_detected with the V1 payload when the client is first seen
and again when its reported state changes, at most 10 times per run; a
restart into the same state stays silent.
- Session Info shows the latest detection; the V1 sentence about disturbed
event sequences is gone.
- Privacy page: device context names the Configuration Manager client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ConfigMgr client installed as a Win32 app registers its device-preparation
policy provider after its own install, with InstallationState=1, and nothing
completes it. The tripwire then reported a stall long after device
preparation had ended.
- EspPhaseSnapshot.DevicePreparationOpen decides whether device preparation
can still hold the ESP (it runs or failed, or the phase is unknown).
EvaluateStallTransition takes it as a parameter; the detector reads the
phase through an injectable reader.
- The cross-restart fingerprint applies the same filter, so a restart does
not report the same stall again.
- The message names the wait: Setup/Apps providers hold the ESP without any
timeout, device-preparation providers until their timeout.
- The diagnosis names a stalled provider only for a failure in its ESP
category; the waiting list of an unfinished session stays unfiltered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security finding (CWE-863): the SignalR user id was the bare UPN and every revoke cut that UPN across the
hub. A tenant admin could add any UPN (a Global Admin, another customer's operator) and remove, disable or
demote it in a loop, restarting that person's live connections in every tenant.
- Negotiate binds the user id to the sign-in tenant plus principal key from the validated request context
(SignalRGroupHelper.UserId); a caller without a principal key negotiates without a user id, since every
revocable join gate needs the key.
- ISignalRNotificationService.DisconnectUserAsync takes the tenant and the key. LiveStreamRevoker is its
only caller and names the tenant each grant resolved under: member routes the route's tenant,
self-service routes the managing tenant, Global Admin routes the tenant of the UPN's identity binding.
Replacing or deleting a binding now cuts the streams signed in under the previous one.
- A self-service assignment confers scope only while its assignee is bound to the owning tenant, so a
self-service grant resolves under the owner alone and the owner's revokes never need another tenant.
- Remove, disable and enable of a UPN without a row in the tenant answer 404 and change nothing: no audit,
no push revoke, no end of an assignment, no cut.
- GlobalAdminService.DisableGlobalAdminAsync and its repository method had no caller and are removed.
- Tests pin the finding's attack, the 404s, the self-service gate, the binding routes and the call sites;
each part was mutated back to the old behaviour and failed its tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two follow-up findings of the security scan against D-378.
- IsPromotableUpn is a shape test (contains '@', no whitespace, no #EXT#); the "System" prefix
test is gone. It rejected real members such as systemadmin@… although every sentinel already
fails the '@' test, so such a tenant got no seat and fell back to first-caller-wins.
- BuildAuthResult keys the first-user guard off a recorded OnboardedBy, not off its promotability:
a recorded value nobody can seat still blocks every other roleless caller.
- RevokeAsync records the revoke (PreviewWhitelist row "revoked") before it deletes the activation,
fail-loud; a re-approve never clears it, and activation seats nobody while it exists. A member
the tenant removed stays removed whatever a Global Admin's revoke + re-approve does.
- Every successful first-admin seat (activation and first sign-in) writes the tenant's audit entry
CREATE TenantAdmin, fail-soft, through a new longest TenantAdminsService constructor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Security finding (ReDoS / CWE-1333): a Tenant Admin of any tenant or a Global Reader could send a catastrophic pattern with 200 non-matching lines to POST rules/gather/test-pattern and cost ~200 s of a worker thread per request (one swallowed 1 s timeout per line), repeat it up to the portal budget and disconnect at once — nothing cancelled the work, nothing limited concurrency, the pattern length was bounded only by the body cap, and RegexOptions.Compiled emitted IL for every caller pattern.
- TestLogPatternFunction: the first RegexMatchTimeoutException ends the test, a 2 s EvaluationBudget ends it when every line is merely slow, and the invocation CancellationToken (client disconnect, host shutdown) ends it between two lines — without throwing. Lines after the stop get no row; LogPatternTestResult.StopReason (regex_timeout | time_budget | cancelled) and a note name the stop and the unevaluated range. A timeout on the last line is a complete test.
- ValidateRequest rejects patterns over 2,000 characters (the MCP schema's cap) before any Regex is built.
- BuildAgentRegex keeps the agent's semantic options and its 1 s match timeout but drops Compiled: the flag changes speed only, never a match result.
- TestLogPatternGate (singleton, limit 2) is entered before the regex is built; without a free slot the function answers 429 RateLimited with Retry-After: 3 and never queues. Stops and refusals log at Warning.
- Shared: LogPatternTestResult.StopReason (additive); manifests and wire types regenerated. MCP: the tool description says that the first timeout ends the test.
- Tests: pattern cap, interpreted construction with the agent's timeout, catastrophic pattern stops after one timeout (was ~200 s), last-line timeout is no stop, budget and cancellation stops, gate slots.
Submodule pointers: internal (plan, D-379, backlog B-66p/B-j42), .claude/commands (platform-health marker).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy
admin row a few storage round-trips later. In between the tenant was activated with no
members, and auth/me seated any roleless caller of that moment as Admin; every member of the
customer's Entra tenant could poll auth/me across the auto-approve moment.
- TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional
insert, best-effort, fresh config read), then the activation row (still the arbiter), then
the welcome mail. The two tables cannot share a batch, so "before" is the guarantee.
- AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes
to that UPN only; other roleless callers stay roleless. Without one (rows from before the
field existed) the first sign-in still becomes the admin. The grant logs at Warning.
- Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync,
409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync
replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check.
- Dead after this: IsTenantAdminAsync on the service, the repository interface and the table
repository, plus its test.
- Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the
conditional write; each part was mutated back to the old code and failed its tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Findings of a Claude security scan against b4af8c7, each measured first, plus
the MSI channel incident a parallel session found.
- Installer 2.8 extracts the agent ZIP with its own containment check
(Expand-AgentPayload): every entry is resolved against the payload folder
before the first write, a rooted or escaping name refuses the package. The
inbox Expand-Archive of Windows PowerShell 5.1 already refuses such entries
(measured), so the finding was not exploitable as reported; the guarantee is
now the script's own, pinned by tests, and Expand-Archive is banned.
- Installer 2.8 also moves PowerShell's own location out of the agent folder
(Set-Location): on the MSI channel the location pointed at the deleted Msi
folder and the agent start failed with error 267 since 2.7 went live. When
the agent program never started, the Agent folder is removed again so a later
run can install; the stderr line is flattened to one line.
- Run folders younger than an hour stay (a loader may still run there); the
loader's log is taken only from a run folder that is ours.
- Loader 1.4 (minimum installer 2.8) checks before every download attempt that
its run folder is still a real folder of ours, opens the download for reading
before the signature and identity checks and holds it until the run ends, so
what it checked is what runs and nothing can replace or delete it meanwhile.
- Agent: the protected folder security names Administrators as owner; a folder
with another owner or a reparse point is not taken as protected, reparse points
are refused, the base is created protected, install mode protects base and
Agent and refuses to install when it cannot; every start re-checks both.
- Bootstrap MSI 1.3.0 installs its loader under Program Files, where a standard
user cannot create the folder first.
- Tests: containment table, hour rule, held handle, owner and junction tests
(ElevatedFact for the owner assignment), MSI location contract; harness
scenarios for an escaping package (installer and OOBE script), the hour rule,
the Program Files layout and an agent that does not start, with the install
stand-in starting a real program from PowerShell's location. OOBE golden
regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After auto-activation, a third of new tenants' first admins were answered by
auth/me without a role and sent from the activation page to /progress. The
auto-approve worker promotes the onboarding member on another instance, so the
portal's caches never hear of it: auth/me combined a cached "no row" (refreshed
1.5 s after every activation poll by feedback/status) with a fresh member list,
and neither the role nor the first-user auto-admin applied.
- TenantAdminsService caches only an enabled membership; a missing or disabled
row is read on every call. IsTenantAdminAsync reads through the same cache.
- auth/me takes the caller's row and "the tenant has any row" from one uncached
partition read (GetMembershipSnapshotAsync); disabled and application rows
still count as members.
- PreviewWhitelistService caches only an activation; "not activated" is read on
every call, so the activation page leaves at the first poll after approval.
IsApprovedFreshAsync is folded into IsApprovedAsync.
- Removed the dead ClearCacheForTenant, GetMemberRoleAsync and
CanManageBootstrapAsync.
Tests reproduce the sequence over the real caches (auth/me and the first
dashboard request); each part fails against the old caching.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub Actions runs powershell steps with $ErrorActionPreference = 'stop', and
under Windows PowerShell 5.1 a stderr line of a native process redirected with
2>&1 then ends the test: the three publish refusals failed in CI although the
publish refused as it should. The publish dry runs now start pwsh through
System.Diagnostics.Process like every other scenario. Local run under 'stop':
199 of 199.
[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Loader 1.3 downloads into a protected run folder of its own
(%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
is signed by the pinned publisher and declares the installer role and version
2.7 or later, removes the folder after a successful run and never touches the
agent folder. Unbound arguments now reach the installer by name; the array
splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
read HKLM through the 64-bit view (a 32-bit host never saw the deployment
marker), guard 5 is covered by the agent check. An agent's folder stays as it
is, a skip without agent removes the folder, an install starts from an emptied,
protected folder and stages the ZIP in a run folder. It moves the MSI's working
directory out, writes stderr only on failure and removes the copy older loaders
left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
(#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
(loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
a real standard user.
[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signup list under the chart now links every domain to
/admin/tenants/management?tenantId=…, the deep link the ops events
"View tenant" button already uses, so the list opens narrowed to that tenant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend repeats AdminAction on every telemetry response while the session
row carries it. The agent handled each echo anew: a new admin_marked_session
with an immediate upload, whose response carried the next echo. With a running
agent on a Mark-Succeeded session that looped at the device rate limit until
the agent ended; Mark-Failed ended the agent and stayed bounded.
- ServerControlPlane: AdminActionLatch admits the first AdminAction of a
process; every later one only logs at Debug.
- AdminAction=Succeeded runs the agent's own termination pipeline with outcome
Succeeded, like Failed: what the portal's confirmation dialog and the docs
promise, and what the restart path already did.
- HandleAdminActionTests: Succeeded terminates; repeated echoes, a later
different outcome and concurrent callers are handled once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the public host a telemetry initializer adds the referrer's host and
the utm_source, utm_medium and utm_campaign tags of the entry URL to every
page view and custom event. Both come from the document itself
(document.referrer and the navigation entry), so nothing is stored and no
identifier is added; metrics, dependencies and the portal stay as they are.
The hero and final get-started buttons and the three Pro plan links now
navigate client-side, so landing, plans, get-started and the sign-in click
stay one page load. A guard fails on a plain <a> to /get-started or /plans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).
The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.
Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.
Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.
Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code-signing identity trusts only environment:release now, so build-agent
release, build-msi sign-loader and release, and publish-scripts publish run in it.
Every job that federates to Azure carries an environment; the ref is no longer a
trusted subject. Help texts of the storage and endpoint helpers follow: the local
build publishes nothing since D-354.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A TpmPssUnsupported report classifies the registration start that failed,
not the device: devices with older TPM firmware fail during setup and
connect after it, on 24H2, 25H2 and 26H2 alike.
Agent: every distress report carries the OS build and the Windows setup
state at send time; the TPM-PSS report adds the TPM manufacturer and
firmware and a language-neutral message with the NCrypt codes of the PSS
signs, the native code of the failed handshake and up to two Schannel
System-log events since the registration started (numeric properties
only). The TPM and System-log reads run in parallel for at most 5 s. The
send is awaited, since the run exits right after it. An attempt that got
an answer resets the last exception, so the probe never classifies an
earlier attempt. The run's last error carries the native code. Win32_Tpm
is read by one shared TpmInfoReader (tpm_status unchanged).
Shared/backend: one body cap for both sides (DistressReportMaxBytes 2048);
the agent shortens its message to stay within it. The endpoint keeps OS
build and setup state only in their format, stores the three fields and
adds them to the log and the custom event. GET audit/tpm-pss-unsupported
returns TPM version, OS build, setup state and connectedAt: the last
upload of a certificate-authenticated session of the serial after the
device's last report (bootstrap sessions excluded, six serials per
SessionsIndex query). The bell says the device could not connect and may
connect after setup and a restart.
Web: the Incompatible TPM panel shows TPM and Status columns, uses the
generated types and no longer says "never" or names a Windows version; the
GA distress detail shows the new fields, Cloud PC and the certificate.
Tests: probe message, Schannel tokens, native codes, payload cap, awaited
send, context bound, last-exception reset, connectedAt rule, filter
comparison limit, repository round trip, panel render. A device serial
from production data in a test fixture is replaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only
- One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send
- The channel also gates session-watch completions and the device test; paired devices stay listed while it is off
- Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in
- SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact
- The legacy single webhook accepts URL webhook providers only
- Portal: Conditional Access hint in the channel editor, neutral role texts
- Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates"
- Privacy, about and FAQ describe push notifications
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured in production with 300 ms latency, router.prefetch loaded only the route tree while auth/me was in flight, and the replace still fetched the route payload after auth/me: no gain on the critical path, one to three extra requests per sign-in return.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MSAL's in-flight redirect state stays in its tab. storeAuthStateInCookie mirrored it into origin-wide cookies that MSAL reads first, so two tabs signing in at once overwrote each other and the first one failed with state_mismatch, stuck on the landing. A guard test pins the cache config.
- AuthGate keeps the auth-pending overlay for as long as the browser is signed in, so it lifts only once the target route has committed; it used to lift when loading ended and exposed the landing for the whole client navigation. The target's route payload is prefetched while auth/me is in flight.
- A sign-in return that carried a code but ends without an account starts one fresh sign-in from "/", keeping the saved deep link; any further failure in the tab hands over to ProtectedRoute's sign-in card on the deep link, which waits for a click. The auth response is read at module load (the router writes its hydration-time URL back), the tab's temporary MSAL keys are purged before a retry and before the card's sign-in, and loading holds while a redirect leaves the page. New events: auth_redirect_recovered, auth_redirect_recovery_exhausted, auth_redirect_failed (error codes only).
- host.json adds Access-Control-Max-Age: 7200 through the http customHeaders. The Functions host answers every preflight itself and its CORS policy carries no max-age; its custom-headers middleware runs outside the CORS middleware, so the header lands on the preflight too. A guard test pins the entry to exactly that header. The CORS startup log goes: it read settings this host does not use, at a level that never reached the telemetry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section reads the tenant configurations from config/all and derives everything in the browser: no endpoint, no counter, no backfill.
- Customers: new tenants per UTC day (per Monday-anchored week beyond 90 days), the window's best day, weekly average, the last 7 days, the total and the cumulative gain; 7/30/90/365 days in ?days=; clicking a column lists that bucket's tenants with their edition. A signup is a configuration row with a domain. The section is the first Metrics entry and the target of /admin/metrics.
- Charts: AppBarChart takes a category formatter, a custom tooltip, a column click and whole-number ticks; AppLineChart can hide its legend and use whole-number ticks. Existing callers get the same props as before.
- The Monday-of-week helper moves from the Fleet Health timeline into lib/timeWindow; the Stat tile duplicated in two metrics sections becomes one component, and the duplicate baseline loses that entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of them had a production caller. DelegatedAdminService.GetAssignmentsForUpnAsync and GetAssigneesForTenantAsync, DelegatedAdminService.SetStatusAsync with IAdminRepository.SetDelegatedAdminStatusAsync (only a test called it; the operator revoke is RemoveAsync), and IAdminRepository.GetDelegatedAdminAsync and IsMcpUserAsync, which only had their own implementation.
Revoke_InvalidatesCache now pins the revoke the operator route runs (RemoveAsync); Disable_InvalidatesCache covers SetEnabledAsync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing or disabling a member under Access Management left their assignment to the tenant's self-service group in place, and the delegated scope never re-checked the membership, so the person kept reading every managed customer.
- Scope: an assignment to msp-{owner} confers the group's tenants only while the assignee holds an enabled TenantAdmins row in the owner (inside the cached row resolution, fail-closed, Warning when ignored). Operator groups and direct grants are unchanged.
- Access Management: remove and disable end the self-service assignment (DelegatedGroupAccess DELETE under every managed tenant, reason member-removed / member-disabled) before the existing stream cut. Re-enabling restores nothing.
- Assigning requires an enabled member and re-reads the membership after the write; the Global Admin group route follows the same rule for a self-service group (enabled member of the owner, bound to it, read-only).
- The SignalR group join resolves the delegated scope uncached, so the re-join right after a revoke cannot be admitted by another instance's warm cache.
- Web: Delegated Access offers only enabled members and says that removing or disabling a member ends their access; the Global Admin page hides the role select for self-service groups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ErrorCodeCatalog.TryLookupDetailed normalised any 0x-prefixed value without
checking its characters and then ran int.Parse on the low word, so a value
such as 0x8007zzzz threw FormatException. The backend enricher runs that
lookup over device-authored event data on every GET /api/sessions/{id}/events,
so one such event answered HTTP 500 for every reader of the session timeline.
- The catalog parses the hex body once with uint.TryParse (hex digits only)
and derives the HRESULT_FROM_WIN32 low word from the number; it now returns
null for any input instead of throwing.
- ErrorCodeEnricher.EnrichEvents isolates each event: a failed lookup leaves
that event unenriched, logs a warning and the page still returns 200.
- Web errorCodeMap.ts gets the same hex-only check: parseInt read
0x8007643z as 1603 and labelled the garbage ERROR_INSTALL_FAILURE.
MCP already validated; a test pins it.
Tests: the reported shapes return null, an exhaustive sweep of short tails
after every prefix never throws, and the enricher keeps the rest of the page
enriched when one event fails. Against the previous catalog 10 of the new
tests fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five platform reads take ?tenantId= only as a filter, but their QueryParam
scoping also admitted a delegated (MSP) reader of the named tenant:
global/session-reports, global/rule-submissions, global/metrics/mcp-usage,
global/metrics/mcp-usage/daily and global/metrics/verdict-calibration. They
carry operator data (admin notes, contact data and reviews, an organization's
accounts, classifier diagnostics), so they are catalogued None now; the
handlers still read ?tenantId= themselves, and Global Admins and Global
Readers keep the filter.
- A catalog ratchet in PolicyEnforcementMiddlewareTests lists every platform
route a delegated caller reaches; a new one fails the build until it is
added on purpose. The TenantScoping.QueryParam doc states the rule.
- The middleware took the cross-tenant decision on the first ?tenantId=
value while the handlers read all values comma-joined. It now decides on
the joined string, with ASP.NET Core's parser (the handlers parse Url.Query,
where System.Uri has already escaped a stray '%').
- The rule pages show a delegated reader the admin-only hint instead of the
submission list, as they already did for an Operator or Viewer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rating from the feedback bubble reached only the direct Telegram ping: it was the one customer
submission without an ops event and a bell. It now raises FeedbackRatingReceived (Tenant, Info) and a
Global Admin bell, so an alert rule can route it to the Push channel. Message and details carry the
stars and the comment length, never the comment or the sender; the Telegram ping stays until it is
retired. The answer route admits members only (MemberRead, like feedback/general): it now feeds an
operator alert, and the bubble only ever asks members.
The alert rules page was linked from no menu. Ops > Alert Rules points at it, and a search in its
header narrows the rules by event type and category; it filters the display only, so a save keeps
every rule. navSectionReachability.test.ts holds the sidebar and the section registries together in
both directions. The ops events page and the rules search share one SearchInput.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device model table on the app detail page put the terminal-only failure
rate (failed / (failed + succeeded)) next to an Installs count that also held
in-progress, incomplete and skipped rows, so 11 failed of 28 read as 50%
without a way to check it.
- Version and device-model rows carry Succeeded on the wire.
- The device model table shows Finished (the rate's base) with a muted
"+N not counted"; the Total card names the same rows.
- The PDF report gets Finished and Not counted columns in both tables and
the hint on Total installs.
- The device-model sample floor counts exactly the rate's base; skipped rows
no longer count toward it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A portal link with ?whats-new= opened without a session flashed the panel,
went through the sign-in, and came back without it: MSAL lands on the
origin root and AuthGate reaches the saved link by a client-side
navigation, while WhatsNewPanelHost (root layout, stays mounted) read the
parameter only once on mount. It now follows the search parameter and
opens once per arrival; a protected page waits for the session, so the
panel no longer flashes before the redirect or counts the open twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Usage Metrics (tenant page and the platform section) shows the window's
sessions in three groups that each sum to the session count:
Autopilot v1 or Device Preparation, Entra or hybrid joined, and the
Autopilot deployment mode (user-driven, self-deploying, pre-provisioned).
Type and mode follow the fleet enrollment class; user-driven no longer
counts the IsUserDriven flag, which every registration sets. The counts
come from the session rows on each request, so every retained session is
covered without a counter.
- DeploymentTypeMetrics gains the new counts and shares; the usage
projection reads EnrollmentType, IsSelfDeployingProfile, IsHybridJoin
- platform and tenant compute share one builder per block
- Platform Usage takes ?days= like the other analytics pages
- the usage pages share their blocks (components/UsageMetricsBlocks.tsx);
enrollment class labels and the time-attribution segments each live in
one place, the duplication baseline shrinks by seven clones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A push carries only category, event and severity for an ops alert, so the
portal link is the way to the details, and ops alerts had none: the event
id was minted inside the save and never reached the dispatch.
Backend: SaveOpsEventAsync sets OpsEventEntry.Id (OpsEventId,
{Category}_{RowKey}); WriteAsync hands it to the dispatch, and BuildAlert
adds "Open in the portal" -> /admin/ops/?eventId=<id> on the plain and the
payload path, so every ops channel (push, Telegram, Teams, Slack, Discord,
e-mail, JSON) gets it. GET global/ops-events?id= is a point read (400 for a
malformed id, an empty list for a deleted one). hardware_rejected links to
the hardware whitelist (one path constant, also for the bell); the session
watch link names the session's tenant; TenantSignup's portalPath keeps its
tenant query. Only a session link makes a push session-bound: tag and Topic
no longer key on any "id=" in the link.
Web: /admin/ops?eventId= opens that event's detail from the point read
(matched by id), and the detail links to the tenant and to the event's
related page (same-origin paths only) besides the session, which now
carries the tenant. CopyButton and the cursor pager footer are shared
components instead of four and three copies; the duplicate baseline shrinks
by five entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the event limit or the platform operator blocks a session's device, the
session now carries a block record: Block* fields on the session and its index
row, a server-authored device_blocked timeline event, an interim analyze run for
that event and, on the auto path, an audit entry. The block list itself stays
platform-only.
- Portal: a card above the failure snapshot says the monitoring was paused
(orange) or ended (red), when, until when and, for the event limit, the
session's event count against the limit. The Blocked pill reads the record for
every role; Global Admin mode still adds the live block list.
- A Kill is device-wide until it expires, so its record carries the end and the
card says new enrollments on the device are not monitored until then.
- Status: the stalled and timeout reasons name the block instead of a silent
agent; the classification is unchanged.
- Rules: ANALYZE-ENRL-006 explains the pause right after it (on_event
device_blocked); ANALYZE-ENRL-002 no longer fires for a blocked session.
- MCP: the coverage block of get_session_summary names the block as a gap.
- The agent-facing block message no longer says "by an administrator".
- BlockCause == event_limit replaces ExcessiveEventsAutoActioned as the
auto-action's idempotency marker.
- Privacy page: the event limit next to rate limiting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stall report never overwrote a guard: the ingest branch for session_stalled
runs after the completion and failure chain, and the transition gate let
Stalled through over every status but Pending. An offline batch carrying
session_stalled next to enrollment_complete left a finished enrollment
Stalled until the timeout sweep reclassified it, and a sweep that lost the
race against a completion could do the same.
IsTerminalTransitionAllowed now allows Stalled only over InProgress, on the
normal and the ETag-retry path alike; the separate Pending guard is folded
into it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET apps/fleet-context (MemberRead, QueryParam) counts one app over the
tenant's last 7 days: installed, skipped, failed, incomplete, in progress,
collision rows left out, the three most frequent failure codes. It reads
the apps dashboard's partition query, leaves out the asking session and,
with the culprit's app id, another app of the same name; no session point
reads. AppFleetContextService caches the app's rows per tenant, channel,
name and minute for 60 s (single flight).
The classification is the analytics view's: MetricsMath.IsInstalled
replaces six inline copies, AppsAnalyticsHelper.IsSameApp and
TopFailureCodes are shared by analytics, sessions and the fleet context.
The diagnosis page shows the result as a tile in "Around the failure"
only when other enrollments exist (the read fails soft), with "Show
sessions" on the app page where that page shows the viewed tenant. An
expanded finding shows "Fired in N enrollments in the last 14 days"
through useRuleFireStats and RecentFiresLine, both extracted from the
session page, whose cross-tenant check now ignores case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The diagnosis page is rebuilt on the server headline instead of rule
confidence: a verdict card per status and cause (culprit tiles, ESP
phases, what an unfinished enrollment still waits for), the
circumstances around it, the steps of the finding that explains it,
where the time went, all open findings grouped by role, team notes and
the deduplicated errors and warnings. It reuses the session page's hook
set, refetches the headline on live activity and opens for Failed,
Stalled, AwaitingUser, Incomplete and Succeeded with open findings, also
from read-only and cross-tenant views.
Rule text: lib/ruleText.ts prunes a finding's explanation and steps like
the notifications do (shared cases in tests/fixtures/rule-text-display,
also run by RuleTemplateInterpolator.InterpolateForNotification), then
parses the markdown and fills values into text nodes only. The session
page's analysis section, the rule card and the dry run render through it;
components/findings/EvidenceChips replaces the two evidence-link copies
and covers correlation pairs.
Vocabularies: DiagnosisPrerequisites names the engine's completion
prerequisites (manifest diagnosis.prerequisites, parity fact against
DecisionEngine.CompletionPrerequisites); the page's texts for every
diagnosis vocabulary are exhaustive over the manifest types.
Fixed on the way: useSessionSignalR held the first render's callbacks,
so ruleResultsReady was lost on an already connected hub; a deep link to
an event dropped its hash before the event loaded; the global analyze
rule update answered 500 for an invalid presentation (now 400); the
rule editor kept the presentation block on create and in JSON mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A typed headline built on demand from the session row and every event:
outcome = status; cause from the verdict path (derived for older rows,
flagged as a caveat), and for an explicit failure from the ESP evidence
(failed step, error code, culprit apps, AppX candidate, stuck policy
providers). Plus the ESP phase rollup, the ESP profile, what an unfinished
session still waited for, and observation caveats. Never from rule
confidence: Stalled, AwaitingUser and reconciled sessions get no terminal
rule run.
- App facts come from the session's own app events (the app table has no
intent or targeting); only required apps count, a later install clears
an app, a zero error code is none, and a 0x87d1041c ESP failure ranks
the NotDetected app first (real IME payload shape).
- The event read is strict: a storage error is a 500, never a verdict
built from no evidence. 400 for a non-GUID id, 404 for an unknown session.
- Helpers/EventDataReader replaces three private Data-string copies
(classifier, failure snapshot, time attribution) and the list parsing in
EspBlockingSets; EspBlockingSets.FromLatest replaces two backward walks.
- Six vocabularies in the shared manifest section `diagnosis`; parity facts
pin the full and the minimal shape; route in the policy catalog.
- MCP get_session_summary carries it as `headline` right after coverage,
null when the call fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All 54 shipped analyze rules get role, summary, subject and key facts as
{{signal.key}} templates, each with a minor version bump so the self-seed
picks them up. Values were checked against stored findings, the rows that
receive the block at read time.
- ENRL-001 repeats the outcome only: the 0x87D1041C paragraph and the
placeholder note are gone, labels read "ESP step that failed" / "ESP
error code", remediation is "Find the cause". This changes its alert text.
- ENRL-002 says the sweep "closed" the session (it can end Incomplete).
- ID-002 and ID-004 gain evidence-only conditions (unexpected account
names; IME token failure count and codes); firing and confidence are
unchanged.
- Resolution: facts sharing a label are alternatives, an exact repeat of
label and value is left out (new shared fixture case, web module and
its generated MCP copy).
- Ratchet: every built-in presentation passes the save validator, uses
qualified tokens only, and each token names a condition of the rule and
a key its source records (RuleTemplateInterpolator.TokenRegex internal).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Analyze rules can describe how a finding is shown: a role (outcome, cause, context,
advisory), a one-line summary, the subject it is about and up to six key facts. Like the
explanation, the block is stored raw on the result and resolved where it is rendered.
- Templates: a qualified token {{signal.key}} reads one key of one condition's evidence,
so counts, durations and correlation fields are reachable. RuleEvidenceKeys (Shared,
exported to the manifest) lists the keys each condition source records; tests pin it
against the evaluators.
- Backend: PresentationJson on AnalyzeRules and RuleResults, RuleVersion on results,
ValidatePresentation on create/update, and results stored without a presentation get
the current rule's at read time.
- Web + MCP: lib/rulePresentation.ts resolves the block (unrecorded facts left out, summary
falls back to the explanation). The MCP now runs generated copies of the web modules
instead of a hand-kept mirror (npm run generate:mcp-logic, freshness tests in both
suites); the duplicate baseline shrinks by one.
- MCP output: get_session and get_session_summary return the resolved block (issues gain
ruleId, role, summary, subject, facts); test_analyze_rule previews it; validate_rule
checks qualified tokens against the key catalog and lints the subject.
- Shared case files: rule-template-interpolation gains the qualified-token cases,
rule-presentation is new.
No rule carries a presentation yet; renderers behave as before until rules gain one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first device on 2.0.1486 reported no bootstrap log at all. Installers up to v2.3 write
bootstrap_agent.log, and the MSI's own downloader (before the loader of 2026-09-08)
msi-bootstrap.log; the reader knew only the names from the rename of 2026-08-18 on, so the
old directly assigned installers that agent_started is meant to count read as "no bootstrap".
- The installer log is the more recently written of bootstrap-script.log and
bootstrap_agent.log; the MSI channel also covers msi-bootstrap.log, which has no version.
- Tests: the old installer name, both installer logs (the newer one counts), the old MSI
downloader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Install-AutopilotMonitor.ps1 v2.6: Get-AgentPayloadRejections (Test-FileCatalog
-Detailed; signature Valid, signer -like the pin, files exactly as catalogued) and
Install-VerifiedAgentPayload (extract into Agent-Install, verify there, then move into
the agent folder; a rejection deletes the staging folder, so the next run starts clean).
Replaces the name-filtered Get-UnverifiedAgentBinaries; the dead "already installed"
branch behind guard 5 is gone.
- OOBE go-script: embeds the installer's marked block verbatim (EmbeddedResource) and
verifies before --install; pin and catalog name come from the Shared constants; the
golden is updated, new tests pin the embedding and the order check-before-install.
- build-agent.yml: the release verify step dot-sources the installer and runs its own
function on the final ZIP.
- Pester: every decision branch, a real unsigned catalog, the staging flow (64/64).
- Privacy page: the catalog claim; LAST_UPDATED.
Merge only after a catalog ZIP is live on stable (plan step V0): pushing this to main
publishes the installer to every loader on its next run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
%ProgramData%\AutopilotMonitor inherited ProgramData's "Users may create files and
folders" entry, so a standard user could add files to the folder the SYSTEM agent and its
boot task start from. The agent outlives the device phase of OOBE (user phase, desktop,
reboots until the enrollment ends), and the payload catalog is checked at install time
only.
- AgentDirectoryAcl.EnsureProtected: protected DACL, SYSTEM and Administrators full
control, Users read and execute, inherited by every subfolder; idempotent, never blocks
the agent.
- Install mode applies it after the agent directories exist; every runtime start
re-checks it, which also reaches agents that arrived by self-update.
- Tests: protected shape, apply once then leave alone, existing subfolders take it over,
a widened or unprotected DACL is not taken as protected, a missing folder is not created.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- build-agent.yml: input channel (release | dev; dev writes the lab blobs only and
refuses stable/release). Every third-party PE needs its vendor's embedded Authenticode
signature (signtool verify /pa, read-only). AutopilotMonitor.Agent.cat, a signed
SHA-256 catalog over every payload file, is created after Authenticode and before the
ZIP; the device check runs under Windows PowerShell 5.1 on the final ZIP.
- Agent: SelfUpdater step 5b verifies the staged payload against the catalog before any
file is swapped (PayloadCatalogVerifier, Test-FileCatalog in-process; a rejection means
no update, skip reason catalog_rejected). agent_started reports which bootstrap
installed the agent, read from the bootstrap logs (BootstrapProvenanceReader).
- Payload: System.Management.Automation.dll (never loaded, the GAC copy binds first) and
AutopilotMonitor.Shared.xml no longer ship; PDBs stay.
- Shared: CodeSigningPublisherPattern, AgentPayloadCatalogFileName;
CodeSigningParityTests pins them against installer, loader and workflow.
- Submodules: infra (build.ps1 local only), internal (D-354, plan, docs), commands.
Installer v2.6 and the OOBE script follow once a catalog ZIP is on stable: a merged
installer change is live on the next loader run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A caller whose token shows it is not a member of the token's tenant (acct = 1, a #EXT# user name,
or an idp naming another tenant, a personal Microsoft account or an external sign-in provider) no
longer seeds DomainName/OnboardedBy or queues the activation, is never the first-user Tenant Admin,
and cannot set the activation-page address in the roleless signup window. Positive evidence only:
a token without these signals stays a member's, and tid/idp are read under their inbound-mapped
names as well. Activation promotes OnboardedBy only; the UpdatedBy fallback is gone and a stored
#EXT# UPN is refused. Explicit grants (TenantAdmins row, app role) still apply.
The once-per-interval log gate of the client-app binding becomes Helpers/LogThrottle and also
throttles the new warning, which the polling activation page would otherwise repeat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
search_events, search_knowledge and search_docs take a query of at most 1,000
characters; search_events takes at most 20 keywords of 100 characters and
scores at most 32 distinct keywords, caller keywords first, reporting the rest
as keywordsDropped with a note. The error-code fallback takes at most 10 codes,
search_docs.section at most 100 characters.
The event scorer compiles the query once (compileConcepts): each event field is
split into words once and its words are looked up by the key of their first
three characters, so scoring an event costs the same whatever the number of
keywords. The matching rule is unchanged; a seeded differential test pins it
against the per-keyword rule. scoreEvents yields to the event loop every 256
events and stops once the caller has gone.
Every paginated tool's continuation goes through one ContinuationSchema
(at most 4,096 characters), and buildQuery appends instead of set: both
URLSearchParams.set paths were quadratic in caller-sized input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every stored history entry starts unread. After a new entry the worker sets the
app badge to the unread count through applyBadge, fire and forget: inside
Safari's declarative push event the setAppBadge promise never settles, so it is
never awaited and never handed to waitUntil (pushSw.test.ts pins it). The
history page marks everything read on every visible load, clears the badge,
closes the notifications whose entries the history holds and shows "New" on
what arrived since the last visit. Unpair clears the badge; the wipe push
leaves it to the next open. The payload repeats mutable inside notification,
where Safari 18.4-26.1 reads it, and carries no app_badge.
Bumps internal (plan, D-350, D-351, concept doc).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Notify me when done" now also fires on the first genuine pre-provisioning
seal ("Pre-provisioning finished") on the gate of the Pre-Provisioning
Completed alert, and the watch is consumed there; watching the user phase takes
a new click. A watch placed on a sealed session lives 30 days instead of 48
hours. EventIngestProcessor.WatchOutcomeFor is the one gate, the watch text
comes from PushSessionWatchService.WatchMessage. The portal button re-reads the
watch on every status change and names what it waits for. The Success hint
says it covers a finished pre-provisioning; the receiver labels the
preprovisioning_* alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.NET's $ also matches before a final newline, so "7c9e6679\n" passed the allow-list that guards
the inbound X-Correlation-ID against CRLF and log forgery and was echoed. Such a value does not
arrive over HTTP, but the gate now holds what its comment promises; a new test case pins it
(red with $).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PushDeliveryService sends with Parallel.ForEachAsync, and RecordingHandler appended to plain
lists without a lock: two concurrent sends could lose a request (Delivered 2, recorded 1), so
Tenant_alert_also_reaches_platform_devices_whose_owners_home_tenant_it_is failed now and then
(once in six full runs while it was traced). A request and its body are now recorded together
under the lock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A security client that inspects TLS strips the device certificate on the way to the backend;
the platform then answers 403 "Client Certificate Required" itself, or a proxy answers with
its own page. The agent counted these like backend rejections: five in a row shut it down,
the scheduled task restarted it at every boot, and only the 48-hour emergency break removed it.
- Origin of every 401/403: only a response that echoes the request's X-Correlation-ID is the
backend's verdict and counts toward the auth shutdown; the stopped-app page still counts;
anything else is foreign (client_certificate_required or foreign_response) and never counts.
- Telemetry: a foreign answer keeps the batch and pauses uploads with a backoff from 30 s up to
15 min, without a timer; collectors and the decision engine keep running, the terminal phase
gets one upload attempt, and the agent still leaves the device when the enrollment ends.
- Distress type ClientCertificateNotReceived (9), once per process, with operation, status,
page title and the issuer of the server certificate; both handlers observe it through a
validation callback that returns exactly sslPolicyErrors == None.
- Registration: an intercepted start retries on at most InterceptedRegistrationRetryBoots
further boots (config channel, default 1, agent clamp 0..5, ConfigVersion 41), then ends
monitoring like the emergency break. The break, this give-up and the config kill signal now
share Program.EndMonitoringAtStart.
- Evidence: telemetry_upload_blocked carries reason, operation, statusCode, pageTitle,
serverCertIssuer and firstBlockedAtUtc; agent_started carries priorRegistration* after failed
starts (a local network fingerprint; only whether it changed leaves the device);
agent_metrics_snapshot counts 2xx responses without the echo.
- Backend: distress type 9, the buffer default, InterceptedRuns on the registration-failure
summary; tests pin the correlation echo on 401/403 and the middleware's place in the pipeline.
- Web: distress labels for TPM Incompatible and Cert Not Received; config report row.
- MCP: session coverage names intercepted uploads and the failed registrations before the start.
D-349
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- What is Autopilot Monitor?: the evidence of every enrollment is recorded while it runs;
analyze rules flag the patterns they know, and for the rest the AI assistant over the
built-in MCP server investigates the session (clients that work with files down to the
raw IME logs of an uploaded diagnostics package) and drafts the rule for the next one
- Key Features: "AI Investigation & Rule Authoring", full width as the seventh card
- Technology & Platform → Integrations: the MCP server (read-only)
The claims carry the same qualifiers as the landing and the docs (D-348).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Landing: section evidence_loop right after the stats ("Never debug the same failure
twice." — capture, detect, investigate, learn), hero sub-line, Compare row "Unknown
failures", capabilities tile. The terminal demo continues with the rule step: validate_rule
and both test_analyze_rule verdicts are the real results of a dry-run against the demo's
two sessions, anonymized like the rest; the window scrolls with the newest line.
- /ai: "Facts first. Raw logs when it matters." with the way back to the raw line; new
section rules ("From finding to rule") with the anonymized trace of the same dry-run;
the step card is shared with Connect.
- Portal: a failed session without any rule finding offers the AI investigation in Analysis
and Guided Diagnosis (the debug-session prompt for clients with MCP prompts, a copyable
full prompt for every other client). A failed session without a diagnostics package on a
tenant without a usable upload links to the diagnostics settings. The analyze-rule editor
tests a draft against a session on the existing dry-run route; create, save and test now
share one rule body.
- llms.txt (evidence loop, two docs links without a redirect), about FAQ, marketing guard
sections, three tracking events; jscpd baseline -1 for the removed payload clone.
D-348
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/auth/mcp reports the caller's client-capped member role in the token's tenant
(tenantRole; reported, never a grant). The MCP server registers test_analyze_rule and
test_log_pattern only for a platform role or a Tenant Admin: their routes are
TenantAdminOrGlobalReader, so Operators and Viewers only ever got a 403. validate_rule,
author-rule and the authoring guide hand a draft to a Tenant Admin otherwise, and
investigate-failed-session and debug-session end by drafting a rule for a cause that can
recur. Catalog ratchet lowered for tenant users (51,000) and delegated callers (55,500).
D-348
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both geo pages returned their CalculatingCard before rendering their own
ProtectedRoute. MSAL keeps the account in sessionStorage, so a new tab,
bookmark or typed URL starts signed out: the tenant scope never settled,
the fetch never started, and ProtectedRoute never mounted to fire the
login redirect. The page spun on "Calculating geographic metrics… 0s
elapsed" forever with no API call.
ProtectedRoute now wraps each page's default export, so the content only
mounts once signed in. Verified in a signed-out headless Edge: production
stays on the card, the fix redirects to login.microsoftonline.com.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workflow gates file was missing from both paths-ignore lists, so every gate
change re-ran the full CodeQL analysis for nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both were repository variables holding the www origin; lib/msalConfig.ts uses them
only as the static-render fallback and window.location.origin in the browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.github/scripts/Get-Endpoints.ps1 defines the storage account (blob and table
derived from its name), the containers, the AdminConfiguration row, the legacy
mirror, the download alias, API, MCP, portal, www, docs and the timestamp
server. Workflows load it once per job; the script publisher and the local
release build call it; the dev-render anchors are built from the alias entry.
deploy-web takes NEXT_PUBLIC_API_BASE_URL and NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL
from the registry instead of repository variables and refuses an empty value.
A new Workflow gates job fails on any host spelled out in a workflow or CI
helper; the enforced list is derived from the registry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every job that runs third-party code (restore, build, tests, npm ci, next build,
wix) now holds only a read-only token and hands its output on as one archive.
Signing, publishing and deploying happen in separate jobs on a fresh runner that
run only this repository's scripts and pinned actions, unpack artifacts outside
the checkout and take no build-job output into a script.
- build-agent: reserve -> build -> release
- build-msi: sign-loader -> build -> release
- deploy-backend: reserve -> build -> deploy (publish output as one tar.gz)
- deploy-web: build -> deploy (SWA token only, no id-token) -> record
- combine-rules: validate (read-only, PRs too) -> publish (push to main only)
- deploy-mcp: same shape; documents docker build as its boundary
Deploy-only jobs run in the production environment. The jobs that sign stay
without an environment for now: a job carries one OIDC subject, and the signing
identity still trusts the ref. oidc-login-test.yml is removed; combine-rules no
longer triggers on the dev branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build-agent.yml tested only DecisionCore (648 of 4,154 agent tests). It now runs
the DecisionCore, Agent.V2.Core and SummaryDialog suites before building the
release binaries, with the reserved build number.
The order matters: Agent.V2.Core.Tests references the agent exe, so its build
compiles the agent. Run after the release build or without BUILD_NUMBER, it
rewrites the exe with another version (a fresh checkout stamped 2.0.1 in a local
simulation). Run first with the number, it stamps the release version and the
Build Agent step stays an up-to-date no-op (same FileVersion, same write time).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Names the helper homes (web lib/, backend Functions/Helpers/) and asks for a
search by what a helper does before writing a new one, which no tool can judge.
Lists the local commands for the new gates, since CI only reports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/quality runs jscpd (5.3.0, pinned with a lockfile like rules/scripts)
over the C# and TypeScript sources, tests and generated files excluded, at 100
tokens. The 95 existing clones are frozen in jscpd-baseline.json. A clone that
is not in the baseline fails, and so does a baseline entry whose clone is gone:
jscpd only reports missing fingerprints, and a stale one would let the same
copy come back unnoticed.
jscpd scans an LF-normalized copy of the tracked and new files under src/,
because with CRLF in a Windows checkout some JSX clones hash differently than
on the Linux runner; local runs and CI now see the same 95 clones.
At 70 tokens a sixth of all clones were identical using blocks, hence 100.
Gate proven both ways with an untracked probe file: a third copy of an existing
helper fails with its location, and a baseline entry without its clone fails.
New CI job "Duplicate code"; Dependabot watches tools/quality with the usual
7-day cooldown. Locally: npm run dup (check), npm run dup:baseline (shrink).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root .editorconfig raises IDE0051, IDE0052 and IDE0060 (non-public
methods) to warning, and Directory.Build.props sets EnforceCodeStyleInBuild,
so the warning freeze turns them into build errors in every project, the net48
agent included.
Cleanup to start at zero, each finding checked for leftover vs. missing
behaviour (none was a feature that silently never ran):
- 38 injected dependencies nobody read: loggers, configuration, and services
whose last read had moved to a live replacement (the IME reseed writes
through the rule repository, the offboarding poison OpsEvent comes from the
handler, the vulnerability gate sits in the correlate handler). Removed with
their constructor parameters and the argument at every construction site.
- unused private members: SelfUpdater.IsNewerVersion, a GatherRuleExecutor
wrapper, test constants and an unused test overload.
RjPhaseCompletedFirstDeployment now replaces the two literal 110s it names.
- unused parameters removed together with their arguments; test fakes bound as
method groups use discards.
- the commented-out backend-URL connectivity check and its plumbing are gone;
the reason the backend stays out remains as a comment.
- DiagnosticsPackageService names the blob in its upload-failure warnings, as
its doc already said.
- stale docs fixed on the way: OnboardedAt no longer claims a maintenance
backfill (shared manifest and wire types regenerated), the vulnerability
functions' policy comments match the catalog, a misplaced summary moved to
its method.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
knip (6.37.0) runs in both TypeScript projects as its own CI step
(npm run knip) next to ESLint. It builds the module graph, so "nothing
imports this" is checked against every reference instead of a search.
Generated files stay out of scope (lib/generated, src/generated); the
MCP's Dockerfile-run precompute and its manual scripts are entries, and so
is the web's compile-time manifest check.
Cleanup to start at zero:
- web: four orphaned components (AdminPageSections, SettingsMenu, UserMenu
(the navbar renders its own menu) and MarkFailedModal), 17 unused sidebar
icons, four unused config constants (the agent-download host allowlist lost
its only reader with the /go route in July), the push device-status union
that existed twice and was used nowhere, an uncalled push error helper,
unused barrel re-exports, default exports duplicating named ones, four
superseded DTO interfaces, and a JSDoc type import of an undeclared package.
- mcp: two test helpers nothing calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCP server had no ESLint, and since its tests left the tsconfig (to keep
them out of dist/) nothing type-checked them; scripts/ never was. tsconfig.json
now type-checks sources, tests and scripts (noEmit); tsconfig.build.json emits
the same dist/ as before (byte-identical) for npm run build and the Docker
image. ESLint (the web's versions: eslint 9.39.5, typescript-eslint 8.65.0)
runs the recommended rules plus the typed promise rules, and CI's mcp job runs
it with --max-warnings 0.
Findings fixed: 16 type errors in tests (a fixture missing three wire fields,
unknown catch results, untyped mocks); gracefulShutdown was async without
awaiting anything, so the signal handlers returned a promise; two dead imports;
a Function cast replaced by the exact pipeline() signature; an unused test
variable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web app kept helpers in two root folders (lib/ and utils/) and named
route-local helper folders either utils/ or lib/. Shared helpers now live in
lib/, route-only helpers in a lib/ next to their route (app/dashboard/lib,
app/sessions/lib). Generated wire types, manifests, guardrails, error codes and
the lastmod file move to lib/generated/, like the MCP server's src/generated/.
The session-search parity cases sit next to sessionSearchQuery.ts.
Pure move, no behaviour change. Imports, the generators (combine.js,
generate-shared-manifest-types.js, sync-error-codes.js, generate-lastmod.js),
the CI drift paths, the two backend tests and the MCP freshness test that read
these files, and the guard tests' registry path and scan list follow the new
locations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A health row goes once its channel is no longer in the stored channel list of its scope and it
saw no send for 30 days. The channel lists are read strictly, so a failed read skips the scope
instead of reading as "no channels"; the delete is ETag-conditional, so a row a send just
rewrote survives. Runs on the 2-hourly maintenance timer next to the push retention.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every non-Push send, real or test, is recorded in a NotificationChannelHealth row per channel
(own table, server-side status rule, destination fingerprint resets the history). The settings
show a status dot per channel: Operating normally, Failure rate x%, Error with the cause, or No
deliveries yet. Two failed deliveries in a row raise one bell for the tenant admins (or the GA
bell for ops channels) per failing episode; a failing channel keeps receiving deliveries.
Each transport now has one send path that reports its outcome (WebhookTestResult is
NotificationSendResult, the dispatcher's test entry is SendTestAsync); failures are logged with
tenant and channel id. New routes GET config/{tenantId}/notification-channel-health
(TenantAdminOrGA) and GET global/config/ops-channel-health (GlobalAdminOnly).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TableStorageService.GetEnrolledTenantIdsCachedAsync: configured tenants with a PlatformStats counter row (RowKey 'current', TotalEnrollments > 0), 60 s single-flight; fail-open to the configured list with a Warning when the counter scan fails
- Session list pages, cross-tenant stats drain, geo window and app-summary snapshot fan out over that subset (353 -> 127 partition queries per page live); fresh-install fallbacks key on the configured list, a configured set nobody enrolled in is an empty page; audit logs, QuerySessionsByDateRangeAsync and maintenance keep the full list
- Web: cross-tenant follow-up pages fetch max(pageSize, 100) (resolveSessionsFetchPageSize); first page and own-tenant list unchanged
- Tests: CrossTenantFanOutTests (enrolled subset, counter 0 / no row, fail-open, unconfigured counter row, empty enrolled set never the legacy scan, bound after the filter, geo + apps), DelegatedBoundedAggregateTests harness, sessionsPageSize.test.ts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A session without a Sessions row can never be reached by the cascade, so every row written for it was an orphan by construction. Three layers now keep the platform clean:
- Prevent: IngestTelemetry answers a batch for an unknown session with 410 (same status and body as the cascade lock; request-row marker IngestRefusal = session_unknown | cascade_locked). The derived writers - vulnerability correlation, analyze, manual rescan - stop on SessionDeletionGuard.GetLivenessAsync != Live. The agent treats every 410 as terminal: one TelemetryRejectedSessionUnknown report (exempt from the reporter cooldown), then terminate_session with forced self-destruct (origin session_gone, no final drain, no diagnostics upload); spool items of a foreign session id are dropped at startup and never terminate the current session.
- Detect: OrphanSessionSweepFunction (every 4h at :45, own lease) diffs the Sessions keys against the EventSessionIndex handles - two range drains instead of ~16.8k Sessions point reads per 2h maintenance run - and re-checks every candidate (handle unchanged, row still absent, tombstone) moments before deleting. The in-maintenance sweep raced the retention cascade on their shared 00:00/12:00 tick and reported false orphans; it is gone.
- Clean: every table of the cascade manifest minus the tombstone pair (ratchet in TableLifecycleBucketTests), the inventory counters with the stamp on the handle (claim, then decrement), the handle last with If-Match. A weekly read-only reconciliation of EventTypeIndex against the Sessions keys (Sunday mornings UTC, and on every manual maintenance run) reports residue.
MaintenanceCompleted now carries stepsMs. MCP search_sessions_by_event auto-exhausts empty-but-continuable pages. Manifests regenerated for the new ops event types and AdminConfiguration.OrphanDeepCheckLastRunUtc.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Customer domains, tenant-ID fragments, two full customer tenant GUIDs and
four real session GUIDs had found their way into code comments, test
constants and a fixture comment. They are replaced by neutral descriptions
("field case", "kiosk tenant") and synthetic values; no behaviour changes.
A check of every onboarded tenant's domain and ID prefix against the tracked
files finds no customer identifier left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The retention fanout reads the tenant configuration strictly. A storage
failure used to yield the synthesized default (90 days), which deletes
sessions and prunes rows a Pro-365 or never-delete tenant keeps. Now the
tenant is skipped and counted: tenantsFailed on
SessionDeletionMaintenanceCompleted, which turns Warning. A row that
vanished since the tenant listing is skipped too, instead of being
recreated with defaults.
- GetDeviceHistoryAsync throws on a read failure instead of returning "no
history". Both writers merge into the chain they read and replace the row,
so the old behaviour dropped every older ref. The sweep skips such devices
and leaves the FTR aggregates untouched for that tick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TableKeySanitizer replaces ten private and inline copies of the key rule
(/, \, #, ? become _) and also removes control characters, which Table
Storage rejects in keys and the HTTP layer rejects in a point read's URL.
Keys without control characters are unchanged; a source ratchet forbids new
copies. The dead UpsertSoftwareInventoryAsync is removed.
- VulnerabilityInventoryLoader hands out inventory text without control
characters, so CPE lookup, report text, side-row identity and counter keys
all see the same text.
- A correlation run whose SoftwareInventory counter writes or contributions
side-row fail writes one SoftwareInventoryCounterWriteFailed ops event
(Maintenance, Warning; counts, error type and status, up to five sample
keys) instead of a per-key entry in the tenant audit trail. The counter work
is awaited inside the invocation instead of a detached Task.Run.
- The new type is listed in the alert-rule catalog; shared manifest and MCP
vocabulary regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some installers pad REG_SZ values with NULs. .NET Framework drops only one
terminator, so the rest rode into the normalized software identity and,
server-side, into a table key whose point read the HTTP layer rejects
("400 Invalid URL"). DisplayName, DisplayVersion, Publisher, InstallDate,
InstallLocation, UninstallString and ParentKeyName are now read the way
Windows shows them; a DisplayName of NULs only counts as empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fleet Health, the Software hub (Installs, Vulnerabilities), app details, Geographic
Performance and its drill-down, Usage Metrics and Analyze Rules read ?days= through one hook
(useWindowDays). The URL is the only state: a preset click writes it with replaceState, the
page default stays out of it, and a link to another app on the same route refetches. Pro
reaches up to 365 days through the URL alone; the presets stay 7/30/90 and a value outside
them highlights none.
Cap: platform roles 365 in every scope, everyone else the viewed tenant's edition cap from its
feature flags (Community 90, Pro 365), read only when the URL asks for more than 90 days. The
cap never rewrites the URL. Fleet Health draws weekly bars beyond 90 days, the Analyze Rules
sparkline keeps its width and bundles days beyond 30, long windows keep their own progress
estimate. The duration-regression bell's days=35 link no longer snaps to 30.
The string presets and their six fall-through mappers are gone; Usage Metrics passes days to
the API, the Software hub and Geographic Performance share their defaults with the pages that
link back to them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First-time-right, time-attribution and rule-statistics rows lived on fixed lifetimes (180,
180 and 90 days), a storage bound reused from the never-pruned tables, never weighed against
tenant retention: a Community tenant could read first-time-right rows for days 91-180 through
the API and MCP, a Pro tenant keeping 365 days lost them after 180 and its rule statistics
after 90.
The retention fanout now prunes per tenant, before its session scan: these rows at the
tenant's effective retention (downgrade grace included), the audit trail at
max(180, retention) up to 365. The maintenance cleanups become outer bounds and platform
lifetimes in DataLifetimes: no tenant row beyond 365 days, global first-time-right rows 365,
time attribution 180, global and legacy rule statistics 90, the platform audit partition and
legacy audit rows 180. Device histories already follow their sessions and stay as they are.
The device-journeys window goes to 365 days (route cap and MCP get_device_history). The
SessionDeletionMaintenanceCompleted ops event carries tenantRowsPruned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tenant that is Pro only through its managing organization ("Pro (MSP)") could not save a
data retention above 90 days: the patch and revert paths validated against the raw table row,
which never carries the read-time projection, so the edition resolved to Community while the
feature flags and the settings slider offered 365.
The validator now gets a projected copy of the stored row. The row itself stays
projection-free, because the exactly-these-fields verify diffs against it; a projected row
would read as drift and roll every save back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome and Edge run the timers of a tab hidden for five minutes once a minute; the client then misses
its keep-alive, the service closes the connection and the automatic reconnect restores it, a loop every
minute in every background tab. lib/signalrPark.ts, a pure state machine tested with an injected clock,
parks the shared connection after four minutes hidden and resumes it on the first visible signal. The
resume ends a gap, so every view re-reads through useSignalRResync. A failed resume retries with backoff
up to 30 s until the next park; no park starts during an accessRevoked restart. Telemetry:
signalr_parked { hiddenMs } and signalr_resumed { parkedMs, transport }; park and resume emit neither
signalr_disconnected nor signalr_connected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pushes only announce changes, so after a gap a view has to check the backend. Only some did: the
bells re-fetched from connection.onreconnected (handlers piled up, nothing after a restart), the
Progress Portal never re-read (a page left open across the end of an enrollment kept its last
state), and the session page re-read only its events. The provider now counts the ends of gaps
(a reconnect after its rejoin, a start of a connection that was up before) in resyncEpoch, and
useSignalRResync(onResync) fires on each once the group joins of that moment have settled, so a
push sent between a re-read and its rejoin cannot get lost; a joinGroup that finds a join of the
same group in flight now awaits it. Bells, Progress Portal (summary and events), session page
(session, analysis, vulnerability report; events keep the join catch-up), diagnosis page,
dashboard list, dashboard stats and fleet health use it; the wasConnectedRef checks are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host decodes %2F to a path separator before routing, so apps/{appName}/analytics|sessions and
their global twins answered 404 for every app whose name contains '/', before any function ran;
a name with a literal escape such as '%20' was decoded a second time. The routes are now
apps/analytics, apps/sessions, global/apps/analytics and global/apps/sessions with ?appName=, and
the unblock route is DELETE devices/block?serialNumber=&tenantId= (serials may contain '/' too).
Function names, policies and tenant scoping stay; req.Query decodes exactly once. Tests run names
with '/', '%', '+', a space and a literal escape through the catalog and the handlers, and the web
builders put the name into the query. Manifest and wire types regenerated (doc text only).
Rollout: backend, then web right after; an app-detail tab on the old bundle 404s until reloaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The typeahead event sent the raw search text (serial numbers, device names) as a custom
dimension. It now sends queryLength and resultCount as measurements, like session_searched, plus
guidShaped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A member without a tenant role who opened a shared session link landed on a session page whose
every read answered 403: an empty page that looked like a valid session without data, its two
error toasts in a bell this user's navbar does not show. ProtectedRoute takes a new opt-in
requireTenantReadScope (the Progress Portal hint's definition of "no role"); the session page sets
it on all three branches and keeps its data hooks inert once auth/me shows no read scope (before
that nothing changes, so members are never delayed). The full-page notice says whom to ask, links
the Progress Portal and the docs article, and reports tenant_role_missing_shown { path } once per
mount.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A receiver holds one pairing, so a Global Admin whose phone is paired in the platform scope
could not get their own tenant's enrollment alerts without replacing that pairing. The push
transport now resolves a tenant channel's recipients as the tenant's Active devices plus the
Active platform devices whose OwnerHomeTenantId is that tenant, filtered by the platform
eligibility rule and deduplicated by endpoint; no other tenant reaches a platform device, there
is no opt-in and no setting. The home tenant's device list shows such a row with
PushDeviceDto.Scope = "platform" (manifest and wire types regenerated); the panel marks it with
a Platform badge and no actions — it is managed under the platform's device list, and the
tenant routes do not know the id. Tests: fan-out incl. a foreign home tenant, a former Global
Admin and a shared endpoint; the list in the home tenant, the owner's and an operator's view,
and the refusing tenant routes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The section is for troubleshooting, not for every visit: a disclosure button in the heading
opens it, the diagnostics are collected the first time it opens (not on page load), Refresh
shows only while open, and a one-line note under the import button says what it does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every session-watch push carried the tag "session_watch" (SystemMessage set Tag = type), so a
browser that honours tags — Edge on the Windows device, iOS for notifications the worker shows
itself — replaced an earlier completion with the next one; two watched sessions finishing
minutes apart (18:38 and 18:40 UTC today) would have shown as one. The tag is now the payload
id: a test, a pairing or a session watch never replaces another notification. Alerts keep
their per-session tag, and the history is keyed by id and keeps every push either way.
The iPhone kept running the worker without the navigate fix through two opens: a home-screen
app that is only resumed never navigates, and the browser checks for a new worker on
navigation. reconcileDevice now calls registration.update() on every open (fire-and-forget).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The worker trace named the error: "TypeError: Call to showNotification() while handling a
push event did not include NotificationOptions that specify a valid defaultAction url".
WebKit (ServiceWorkerRegistration.cpp) rejects showNotification() inside a declarative push
event unless the options carry a valid navigate URL; a notification shown with one is not
displayed directly but replaces the proposed notification when the event ends. Every
displayed notification on the iPhone therefore came from the platform, with data null, which
is why the on-open import recognised none of them.
toNotificationOptions now sets navigate: the payload's deep link, else the history URL on
the worker's origin (opened on the entry); the generic fallback carries the history URL too.
Trace values may be 240 characters so an error text is readable in full. Tests pin the
member on the entry, the generic and the fallback call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first worker trace from the iPhone read "push · notification · session_watch · added ·
display failed": the worker ran and wrote the entry, but both showNotification calls threw —
on iOS the platform shows the proposed notification itself inside a declarative push event.
In the old code show()'s rejection made the event's waitUntil reject at once, with the history
write still in flight; that is the plausible cause of the entries lost today. show() now
resolves to "shown", "fallback" or "failed: <error>" and never rejects (regression test with a
throwing showNotification).
The same trace showed all six displayed notifications as "not ours": getNotifications() on iOS
hands them back without a data object the entry rule recognises, so the on-open import cannot
identify them. The Diagnostics list now shows tag, data shape and timestamp per notification
so an import without data can be designed from evidence; a data member that arrives as a JSON
string is read as JSON.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two session-watch pushes reached the iPhone's lock screen today (PushDelivery 201, the same
payload shape as the test pushes that do arrive) and neither reached the history, nor did the
import of displayed notifications on open find anything. The device holds the evidence, so the
worker now records what it did: one record per push (which event member carried the payload,
type, id, outcome of the history write, how the notification was shown), per click, per
activation and per subscription repair — in meta `workerTrace`, 25 records, values capped,
written inside the same bounded budget as the history write (a database that never answers
leaves no record, which is itself the finding). The status page shows it under Diagnostics:
worker state, notifications still displayed, history rows, the trace, and a manual "Import
displayed notifications" with its count. Nothing leaves the device.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A session-watch push showed on the iPhone but never reached the history, and a pull on the
empty part of the page did nothing. (1) The history now heals itself: on every load the page
asks the service-worker registration for the notifications still in the notification centre
and writes the ones it lacks (entryFromNotification, the rule the click handler already used,
now in the worker core) — a push the worker could not persist is caught up as long as its
notification has not been cleared. (2) The pull gesture is registered on the window (passive),
not on the content wrapper, so a pull that starts below the list counts; the wrapper keeps a
minimum height. Tests pin the entry rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Get started keeps its tick as the acceptance: the CTA hands ?signupConsent=1 to portal (kept per
tab for 30 minutes, removed from the address bar at once), the first auth/me forwards it, and the
onboarding write records DpaVersion together with DpaAcceptedBy (first-login UPN) and
DpaAcceptedAt.
- Any other first sign-in (the navbar "Sign in") sets DpaAcceptancePending on the new row instead.
auth/me carries the flag; ProtectedRoute renders a blocking dialog with the same sentence and
links as Get started (shared TermsDpaAgreementText) instead of the page; POST
auth/dpa-acceptance writes the acceptance for the caller's own tenant only, a no-op 204 when
nothing is pending.
- Tenants onboarded before this carry no flag and are never asked. Activation is unchanged; the
dialog appears after it.
- The three new fields are system-owned like OnboardedBy (patch deny-list, revert-protected) and
projected by config/all, the GA tenant report and the MCP tenant list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both mails were landing in Junk at some recipients although the domain's SPF, DKIM and DMARC
are in order, so the copy loses its marketing markers: the welcome subject names the tenant
("Your Autopilot Monitor access for {domain} is ready", no exclamation mark; the editor shows
it with the domain placeholder), the heading drops its exclamation mark, "Great news" and
"incredibly valuable ... don't hesitate" give way to plain sentences, the closing line is a
thank-you, and both templates keep GitHub as their only feedback link (the personal LinkedIn
link, a second foreign link domain, is gone). User go for the customer-visible wording.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both template mails also land in Junk at some recipients. The templates are already image-free
and inline-styled, so the remaining technical signals were the missing text/plain part (the
provider derived one) and the missing automation header. HtmlToText renders the effective
template (default or operator override) as the text part — head, style, script and comments
dropped, blocks and list items as lines, links with their URL, entities decoded — and
EmailService sends every mail with Auto-Submitted: auto-generated / X-Auto-Response-Suppress,
shared with the alert mails. Wording changes to the customer-visible templates are deliberately
not part of this commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first channel mail landed in Junk although SPF, DKIM (mte1/mte2) and DMARC are in order,
so the content was the tell: a bracketed, shouting subject built from the machine title
"Ops Alert: Platform/PushDeliveryFailed", a raw JSON block, hardly any prose, no text part.
Now: subject "Autopilot Monitor: Push delivery failed (Platform)" (event name as words, no
tags, no severity word), one introductory sentence naming the severity and category, the
event as a heading, facts and the opted-in payload as key/value tables (nested values as
compact JSON, non-object payloads still capped), a visible portal link, a footer that says
why the mail was sent, a plain-text part of our own (auto_text off for alerts) and the
RFC 3834 Auto-Submitted header. Template mails are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A test mail that Mandrill accepted did not arrive; the backend had logged nothing beyond
"accepted". SendViaMandrillAsync now returns the status (sent/queued/rejected) and the message
id, the channel test reports them ("Provider status: queued, id ..."), and the EmailDelivery
custom event carries them as status and providerIds — enough to look a mail up in the
provider's activity log.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WebhookProviderType.Email = 60 joins Telegram and Push behind the Global-Admin gate: it sends
under the platform's sender identity and provider key (the welcome/farewell path,
noreply@autopilotmonitor.com). The channel's Url names one to five plain recipient addresses
(ValidateEmailDestination, no SSRF URL check). EmailNotificationService renders the alert
(EmailAlertRenderer: severity-prefixed subject, escaped HTML with summary, facts, sections,
links, and the structured payload as preformatted text when the rule opted in) and sends one
message per recipient through EmailService.SendMessageAsync; outcomes land in the EmailDelivery
custom event and the log, never in an ops event (a failing e-mail channel must not feed itself).
The dispatcher takes the transport through a chained fourth-parameter constructor so the
existing mocks keep working. ChannelEditor offers the provider to Global Admins with a
"Recipients" field. Tests: routing, validation, gate, renderer, send against a provider stub.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted and RuleSubmissionReceived
(category Tenant, Info) are recorded where the backend pings the Telegram chat directly, so an
alert rule can route them to any channel — the Push channel included. Like FeedbackReceived,
message and details carry ids and a portal path only, never the submitter or a comment (the
user-id column keeps the submitter for the admin view and never reaches a channel). The ops
event is awaited before the response; the Telegram pings stay untouched until they are retired.
Dual-registered (constants, All, web rule list), vocabularies regenerated, health marker row.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- History page: one dropdown built from what the history holds — Platform (ops alerts, one
entry per Category fact), Enrollment (one per tenant alert kind) and This device (the
receiver's own messages) — with counts, a "12 of 48" readout and a clear button while a
filter is active, hidden while there is only one kind to show. The choice is stored on the
device (meta key historyFilter); a deep link to a hidden entry shows everything. A
customer's device holds tenant pushes only, so it never sees a Platform group.
- SessionWatchResponse.Available: GET/DELETE sessions/{id}/watch say whether this person can
watch at all (an enabled Push channel in one of their own scopes where they hold Admin or
Operator); the portal's "Notify me when done" renders only then, instead of letting a tenant
admin click into "enable a Push channel first", which only a Global Admin can do. An active
watch stays available so it can be switched off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A test push arrived as a notification while the history page was open, but the list did not
change until the user left the page and came back. The page now re-reads the local store every
5 s while it is visible, on focus and pageshow as well as on visibilitychange (the worker's
message stays the fast path), and an unchanged read keeps the list's identity so the poll never
re-renders for nothing. A pull from the top refreshes on demand — a home-screen app has no
native gesture — with a damped indicator (pull / release / refreshing) and the device reconcile
folded in; the gesture rule is a pure, tested controller.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Push receiver: delete entries, user-set retention, self-migrating VAPID key (B-y4z)
- History: a long press (or right-click) on an entry opens a small menu — Delete (local only;
the history never leaves the device) and Open in portal when the entry carries a link.
- Retention: the status page lets the user keep the history 0-365 days (default 30 as before,
0 = until the entry cap of 200, which is unchanged and stated); stored in the receiver's
IndexedDB meta, honoured by the worker and the pages when pruning.
- Key rotation is a re-registration, not a re-pair campaign: GET push/device names the active
kid and public key; a receiver whose stored kid differs subscribes again with the new key on
its next open and PUTs the new kid (reconcile action "rekey"); a Stale vapid_key_mismatch row
re-arms the same way. Operator texts (settings template, generator script) follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal, infra (push-relay addendum: delete, retention, key migration)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Push receiver: name a failed subscription repair; the worker re-subscribes with the server's active key
- reconcileDevice returns repairError when the subscribe() or the PUT after it failed (400
InvalidSubscription, 429, network); the status page shows "present, server not updated" plus
the reason instead of a silent "present". Retried on the next open as before.
- pushsubscriptionchange (Chrome, Firefox) asks GET push/device for the active key before it
re-subscribes (planSubscriptionKeys: active key when it differs from the stored kid or no key
is stored, else the stored key, else nothing), drops the browser's replacement subscription
after a rotation, PUTs the kid it used and stores kid + key; never a PUT with an empty kid.
Worker fetches are bounded to 10 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal (push-relay: worker key rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Push receiver: read the declarative notification from the push event (Safari history fix)
On an iPhone the pushes arrived with the right content, but every history entry was the generic
placeholder: with "mutable": true Safari (iOS >= 18.4) dispatches the regular push event with the
proposed Notification in event.notification and event.data null, and the worker read only
event.data. The push handler now takes event.notification first and shows the same content
itself (one notification, entry written at arrival); a tap on a notification the platform
displayed without the worker rebuilds the entry from the Notification's data, and the click
path's history write is bounded like the push path's. Tests pin both paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal (push-relay: Safari declarative-push finding)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Action pins: exact release tag in every version comment (D-331)
setup-node v7.1.0 and upload-artifact v7.0.2 moved the floating v7 tag, so the
"# v7" comments on our still-correct pins (v7.0.0, v7.0.1) stopped naming the
pinned commit and zizmor's ref-version-mismatch raised 11 code-scanning
alerts on main. The other five major-only comments (checkout, setup-dotnet,
cache, attest-build-provenance, functions-action) were one upstream release
away from the same alert.
All 38 major-only comments now carry the tag their unchanged SHA resolves to
(checkout v7.0.1, setup-dotnet v6.0.0, cache v6.1.0, setup-node v7.0.0,
upload-artifact v7.0.1, attest-build-provenance v4.2.2, functions-action
v1.5.7); no SHA moved, Dependabot keeps advancing SHA and comment together.
verify-pins now accepts only a vX.Y.Z comment and names the floating-major
case in its own error.
Verified: zizmor online (GH_TOKEN) reproduced the 11 findings before and
reports 0 ref-version-mismatch after, --min-severity low exits 0; the
verify-pins script passes all 51 uses lines and rejects a major-only comment,
a missing comment and a tag reference in a scratch workflow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (D-331 action pin comments)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (ingest-session-row-reads RELEASED, measurement)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Web Push channel: alerts to paired phones without a third-party messenger (D-327 to D-330)
Provider WebhookProviderType.Push = 50 in both channel scopes. It carries no destination;
NotificationChannelDispatcher takes a NotificationScope from every caller and the push
transport resolves the scope's paired devices at send time (table-backed Admin/Operator,
GlobalAdmin with matching identity binding; Viewer and Global Reader never).
The receiver never signs in: a signed-in Admin/Operator creates a one-shot pairing code
(11 Crockford chars, hashed, 10 min, ETag one-shot, five-failure burn), the phone redeems
it anonymously, the portal session confirms. The phone's only credential is the device
token in X-Push-Device-Token, good for status, re-subscribe and unpair of its own row
(10 calls per minute per device; the anonymous pairing routes 10 per minute per IP).
Payloads are RFC 8291 per device (new BCL-only library src/Push with the RFC vector pinned
byte for byte) with a VAPID key ring and the kid on every device row; the projection reads
an allow-list only (never Sections, DataJson, Failure Reason, Last Failure or the ops
message), serial masked, 2 KB. 404/410 mark a device Stale instead of deleting it and the
receiver re-arms it on its next open (the same PUT lifts a delivery-failures pause); devices
pause when their owner stops signing in (PushOwners stamp in auth/me) through one pause path
and resume by themselves; an owner without a stamp is paused, never deleted outright; role
loss revokes with a wipe push and deletes the row (there is no Revoked state). PushDevices
and PushPairingGrants are credential-bearing: raw-table deny-list, no backup, redacted
dependency rows.
Receiver app under /push/ (module service worker, Declarative Web Push shape, local history,
pairing and status pages, manifest + icons), portal UI (Push provider in the channel editor,
pairing dialog with QR code, device lists for tenant and platform scope, "Notify me when
done" on the session page — the watch row records the watcher's own scopes, so a Global
Admin watching a customer session is notified on the platform-paired phone), 22 catalogued
routes, ops events PushDeliveryFailed and PushEndpointRefused (deduplicated per instance),
maintenance sweep inside the existing run, settings template.
The tenant-scope provider stays Global-Admin-only until the customer release (same
ValidateModel gate as Telegram). Backend 7371, library 125, MCP 997, web 2061 tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal, .claude/commands, infra (push-relay: plan, D-327 to D-330, markers, VAPID generator)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal (push-relay: commit step ticked)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Drop sprintf-js from both lockfiles (Dependabot #114, #117)
GHSA-hp3w-g68c-fv3c (sprintf-js <= 1.1.3, denial of service through unbounded
precision specifiers) has no patched release, so both chains are cut instead.
MCP (#117): sprintf-js arrived via @huggingface/transformers -> onnxruntime-node
-> global-agent 3 -> roarr. global-agent is only loaded by onnxruntime-node's
CUDA install script, which the image never runs (npm ci --ignore-scripts). The
override global-agent ^4.1.3 drops roarr; 4.x keeps the bootstrap export that
script calls, and onnxruntime-node 1.30 depends on the same range.
Rules tooling (#114): ajv-cli 5.0.0 (no release since 2023) pulled js-yaml 3
-> argparse 1 -> sprintf-js. rules/scripts/validate.js now checks the four rule
sets against their schemas with ajv (draft 2020-12) and ajv-formats directly,
and combine-rules.yml runs it in one step instead of four ajv-cli loops. The
lockfile shrinks from 26 to 6 packages; the fast-json-patch override and the
legacy-peer-deps .npmrc (only there for ajv-cli's ts-node peer) are gone.
Verified: validate.js accepts all 149 rule files plus guardrails.json exactly
as ajv-cli did and rejects a corrupted copy (missing field, broken JSON, U+2028
in a guardrail value) with exit 1; clean npm ci + combine.js leave the
generated outputs unchanged; MCP build and 997 tests green; zizmor clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal, .claude/commands (validate.js replaces ajv-cli: B-gzv, rule-builder fallback)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Bump sharp, proxy-addr and source-map-js past fresh advisories (MCP, Web)
- sharp 0.35.4 -> 0.35.5 (GHSA-wq5f-xc86-pv6w, librsvg): the existing sharp
override in both projects now floors at ^0.35.5 so the fixed build cannot be
undercut by a parent range.
- proxy-addr 2.0.7 -> 2.0.8 (GHSA-jqcg-44mw-7w3h, via express; MCP only) and
source-map-js 1.2.1 -> 1.2.2 (GHSA-68fv-2mgg-jv7q, via postcss) are
lockfile-only bumps inside the ranges their parents declare.
No other lockfile entry moved. npm audit in the MCP server is clean.
Verified: MCP build and 997 tests green; Web tsc clean, 2061 tests green,
next build (static export) succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (push-relay: CORS verified, cert-exclusion prerequisite, EU app facts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Web: Next.js 16.3.6 -> 16.3.8 (six advisories)
16.3.8 closes GHSA-3w37-wq28-93x7, GHSA-4jqv-mc3x-m676, GHSA-39w2-rjm5-chcv,
GHSA-f87g-xv8r-7p7x, GHSA-mcj8-r9mp-w47p and GHSA-cjq9-62q9-8jv4 (affected
16.0.0 - 16.3.7). Under output:"export" the cache, ISR and image-optimization
paths never run on the SWA; the dev-server disclosure is local-only. The patch
is taken anyway. next, eslint-config-next and @next/bundle-analyzer stay
aligned and floor at ^16.3.8; the lockfile is pinned to 16.3.8, not the 16.4.0
minor the caret would resolve to.
What npm audit still reports (braces, micromatch, chokidar, fast-glob,
postcss-selector-parser) comes through Tailwind 3 and eslint-config-next,
build-time only; braces has no fix, the rest needs Tailwind 4 (backlog).
Verified: tsc clean, eslint 0 findings, 2061 tests green, next build
(16.3.8, 102 static pages) succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (B-dzs lockfile merge note, B-hxz braces chain)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Backend 1.5.1256 raised IngestTelemetry p50 from 66 to ~100 ms and
RegisterSession from 90 to ~110 ms. Storage metrics per API located it:
server latency of point reads unchanged, end-to-end latency of QueryEntity
alone up from 3.7 to 6.4 ms, every other API flat. The excess times the
point reads per upload is the whole regression, and the request histogram
gained a second mode 46 ms above the first. The only point-read response
that had changed was the Sessions row (~400 bytes of tracker state and
twins), which every upload read twice in full.
- SessionRowProjections: the guard row for the agent routes (Status,
SerialNumber, owner columns) and the counter merge's slice (counters,
anchor, index key, tracker state and twins, the fields non-terminal
batches consume). The guard takes a select and adds its lock columns;
the inventory reader gets a projected overload, the deletion flows keep
their full reads.
- The counter merge returns SessionIngestSnapshot, a narrow type instead
of a SessionSummary built from a projected row. The processor reads the
whole row only on terminal, diagnostics and transition batches or when
the merge returned nothing, and derives the snapshot there. The SignalR
delta and the control signals carry the same fields as before.
- The signal-only upload reads the same slice; registration takes its
lifecycle entry from the guard's row instead of a second read.
- SessionRowProjectionTests: a select-strict harness pins each list. The
merge's write, index mirror and snapshot, the slice mapper, the guard
row's readers and the owner policy come out identical whole or
projected; a dropped column fails there (verified by mutation).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent's ntp_time_check reports offsetSeconds = NTP time - device time,
so a positive value means the device clock is BEHIND. The session card
said "ahead of UTC" for it (a device 10 h behind showed as 10 h ahead),
and ANALYZE-DEV-007 named its two signals the wrong way round.
- SessionInfoCard: the direction comes from deviceClockDirection
(sessionInfoLogic.ts, pinned by a test with both signs).
- ANALYZE-DEV-007 1.1.1: offsetSeconds > 120 is clock_behind, < -120 is
clock_ahead. Conditions, thresholds and text are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Session durations come from device times, and a device clock can be off by
hours for a whole session or change in the middle of one. Every upload
carries the device send time next to the server receive time; their
difference is the device clock error. This change records the session's
start, WhiteGlove Part 2 start and end on the server clock next to the
device values. Nothing reads them yet, so no stored value and no display
changes.
- ServerTime: the rule, defined by tests/fixtures/server-time/cases.json and
run by both the backend (ServerTime.cs) and the web (lib/serverTime.ts).
- Ingest: ServerTime.Tracker, the rule fed one upload at a time, keeps its
state in the primary-only column ServerTimeState and writes
StartedAtServer and ResumedAtServer in the counter merge (no extra read
or write). It follows a session from its first upload only, and only when
that upload carries agent_started; sessions already under way when this
build arrives get their values at completion. On every upload prefix of
every followed case it gives the rule's start and Part 2 start; a retried
upload with agent_started is not counted as a restart.
- Terminal: the counter reconcile, which every terminal path runs, also
computes the rule over the same event scan and writes StartedAtServer,
ResumedAtServer and CompletedAtServer. Fail-soft: a rule failure never
costs the counter reconcile or the ingest write.
- Re-registration carries the four columns from the fresh CAS read through
the Replace (the agent's first upload often races its registration).
- SessionSummary gets the three fields; the index mirrors them, the index
merge drops primary-only columns; manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The backend default of CollectorConfiguration.OobeUpdatePhaseMode is now Active. While Windows
installs its OOBE quality update, the decision engine holds the post-ESP completion window (at most
3 h) and then waits 60 min for the sign-in; a session nobody signs in to ends as awaiting the user
or incomplete instead of failed. Agents still count a missing or unknown value as Shadow. Shared
manifest and generated wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Microsoft switched off the Office 365 connectors in Teams in May 2026. Provider 1,
its MessageCard renderer, the channel editor option and the flat TeamsWebhookUrl /
TeamsNotifyOn* fields with their fallback branch are gone; the value stays retired.
NotificationChannel.IsSupportedProviderType is now the one rule for channel parsing,
channel validation and the flat WebhookProviderType, so a retired or unknown provider
can no longer be stored and come back as a synthesized channel. The channel editor
looks up the placeholder by value instead of by list index. The header-parsing tests
move to NotificationChannel.GetCustomHeaders; manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The time attribution shows the OOBE quality update only once a session ended. While it
runs, the web now derives it from the events it already holds:
- lib/osUpdateLive.ts ports the calculator's update interval for the current window and
labels it from positive evidence that the update finished: running (restarting),
waiting for sign-in from 2 min after its end, or failed/skipped without a restart.
A package waiting for its restart keeps the update running whatever the page reported.
- Session page: a line under the phase timeline, shown after the first complete walk of
the event pages, with a note when the device has been silent for 15 minutes.
- Progress Portal: the headline says that Windows is installing updates and asks the user
to sign in after the update's restart.
- OobeUpdateVocabulary (Shared) is the one vocabulary of the agent, the calculator and the
web (manifest section oobeUpdate); same strings on the wire, no behaviour change.
- tests/fixtures/os-update-live/cases.json pins the calculator and the port
(TimeAttributionOsUpdateLiveParityTests, lib/__tests__/osUpdateLive.test.ts).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An X click counts a dismissal (DismissCount on the person's row); the second ask waits for the
configured pause (FeedbackCooldownDays), the second dismissal ends the asking. A rating ends it at
once. Ignoring the bubble records nothing, as before.
- A rated row is final: a later dismissal or rating (another tab) changes nothing and sends no second
ping. Answers are written conditionally on what was read (ETag), so two tabs cannot overwrite a rating.
- Rows from before the count read as one dismissal when dismissed; the person keeps UPN and name.
- Rules in FeedbackPromptRules; the admin Ratings tab shows "dismissed twice".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- POST feedback/general (MemberRead): text up to 4096 characters, optional reply address, at most
10 per user in 24 hours; one Feedback row per message (PK General), shown in a new admin tab.
- FeedbackReceived ops event without text or sender, plus a bell entry. The help menu gets one
"Send feedback" entry; the dialog links to Submit Logs for Admins and Operators.
- Report uploads: logs are zipped before sending and both forms share one budget derived from the
20 MiB body cap (Constants.SubmissionLimits through the shared manifest). The session exports go
along when they fit next to the attachments.
- Report Session opens to Operators. Comment and contact address are validated; a report whose row
cannot be written fails and its uploaded blobs are removed.
- feedback/status no longer creates a tenant configuration row for an unknown tenant.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first run has no Shell-Core replay (the restart replay re-raises the Hello-wizard start), so an
agent installed late during the ESP missed the update page's start and first half (6a7d241d).
- BackfillUpdatePageTelemetry: on the first run the last 60 min of Shell-Core become oobe_update_page
rows and nothing else — no Hello replay, no ESP exit or failure, no agent_trace, no registry read.
- Live and backfilled names have budgets of 150 each; the first name over a budget sends a
names_capped marker, so a missing name is never read as "did not happen".
- A backfill batch keeps its own last page and moves the live watcher's only with a newer RecordId:
a restart replay's older page start no longer takes over the next live stop, and a first run's
live stop finds the page that started before the agent.
Agent suite 3,436 green; the agent stays on net48.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Field measurement 3: a first-run agent that started after the update page (6a7d241d) left the whole
update in Apps (ESP), failed and skipped updates were reported by nothing, and the lane listed a
failed update's staged servicing stack as an installed KB.
- Time attribution v5: update names before the first observed OobeNDUP start begin the update at the
earlier of the first name and the update's first servicing step (after the window's ESP exit when
one was seen before the name, else the first step of a package the update also serviced later).
The fallback's ESP exit is per window, so a WhiteGlove part-1 exit no longer begins an update in
part 2. OsUpdateSpan carries Outcome (installed/failed/skipped/unknown, the latest evidence wins)
and NotInstalledKbs; Kbs lists only packages that reached Installed. The 2-h sweep recomputes the
last 30 days.
- ANALYZE-DEV-013 (update failed), DEV-014 (not offered after an earlier failure), DEV-015 (update
check ended without a result): warning, never KO; a failure only from explicit names, every
absence condition gated on the agent's names_capped marker; never two at once.
- ShellCoreTracker rows never set the session start (D-315 list), ahead of the agent's first-run
update page backfill.
- The web lane names the outcome and the KBs not installed; MCP get_time_attribution mentions both.
Real-event replay: 6a7d241d os_update 2358 s installed (was 55 min in esp_apps), e7551b82 failed with
KB5124007 not installed. Rule dry runs and a sweep over all 97 update-page sessions since 2026-10-02
hit exactly the five field sessions. Functions 6,918, web 1,701, MCP 997 green.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tailwind 3's divide-<color> rule (`> :not([hidden]) ~ :not([hidden])`,
specificity 0,3,0, plus the dark-mode !important overrides in globals.css)
recolours every child but the first on all sides, so a row's own border
accent only ever showed on the first row.
- Notification dropdown: the purple GA and blue tenant bars are inset
shadows now, so every such row keeps its bar.
- Geographic performance: the red outlier bar moves from the row to its
first cell; it was grey on every outlier but the first table row.
- Tenant config report: the heavier separator between notification
channels and between self-hosted AI clients moves to the cell
(GROUP_SEPARATOR); the divider had shrunk it to a plain 1px line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A concurrent IME writer overwrites lines behind the bookmark, and the overwrite check recovers them a
pass later. Recovered lines were never fresh, so they resolved in the agent's own cached zone: in a lab
the zone was stale (+60, IME wrote +120), recovered result lines landed an hour ahead of the clock and
the platform-script durations were omitted ("n/a" on Install Autopilot Monitor, session d29e27e3, also
on 2.0.1474/2.0.1467). Bytes that read differently at a position were written after that position was
last seen, and IME stamps a line when it writes it, so the last sighting bounds the new line's age.
- Ledger entries carry SeenUtc: set when read or seeded, renewed whenever a verification finds them
unchanged.
- A changed entry below the old bookmark is fresh when the pass is, the ledger held the entry at the
rewind point, and the replaced bytes were seen 0-30 s ago; otherwise the fallback as before.
Re-processed entries are never calibration anchors.
Field replay and longer-running script red before; six mutation proofs; agent suite 3,426 green.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
IME re-runs a failed platform script on later check-ins, and the agent's first pass reads all of
AgentExecutor.log before the IME log, so several runs of one policy shared one slot: the first IME
result took the LAST run's exit code and output (session eff1b413: the bootstrap script "Failed
(exit 0)" with the stdout of its successful retry), every later result went out without an end
block. The exit-code fallback said Success for exit 0 with stderr and Failed for exit 1 without,
which IME never does.
- A later timestamped executor start parks the previous run of its policy (persisted, max 64); an
IME result pairs with the run of its policy and user that started last before the result line;
earlier parked runs of the same user resolve then, so emits keep run order.
- Invocation markers carry the run id; the emitted marker only rises; no dedup path deletes a live
run; a parked emit never takes the later run's D-316 register entry.
- Fallback: Success exactly when the stderr is empty, the exit code only when no stderr line was
read; "write output done … error =" replaces the "error from script" snapshot, empty stdout too.
Backfill replays fail on the old code; nine mutation proofs; agent suite 3,416 green.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Web: the script panel applies the correction to the run its runId names: the saved output replaces the emitted one, a foreign end block's exit code shows as n/a with an explanation, the state follows the corrected data instead of the emitted event type, and the output carries a short "from IME's saved result" note. The timeline marks the original row as corrected later, and the correction event falls under the script-output setting like the other script events. The session page passes the event to the panel; the gather-rule event list knows the type.
MCP: get_session_summary no longer counts a script_failed as an error, ranks it as one or attaches the exit code's catalog text when a foreign correction with IME result Success names its run.
Backend: the correction never heals a stalled session and never counts as a script run.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After reporting a script batch, IME saves every run under HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Policies\<user>\<policy>; ResultDetails carries the stdout IME read from that policy's own result file (the stderr when it failed with error code 99). The agent now checks each emitted platform run against it: a match confirms the output and the exit code of the same end block, a contradiction emits script_output_reconciliation (Warning, outcome "foreign": the saved output replaces the emitted one, the exit code is unverified), and output the log delivered incomplete is completed (Info, outcome "repaired").
A save is paired with the latest run of its user and policy only after the tracker completed a pass that started two seconds after the save was seen, so a stalled poll loop cannot pair it with an older run. Platform completions carry a runId the correction names. Two restart-safe counters (verified, corrected) ride agent_metrics_snapshot and ime_pattern_hits. The new event type is registered in the shared constants, the generated manifests and the MCP event catalog.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two AgentExecutor processes that open the log at the same end write their start blocks over each other. The end block of the one whose start block lost then sat behind the other executor's start block, and position-based ownership handed a Win32 detection script's stdout and exit code to the platform script.
An AgentExecutor.log entry that is not exactly one CMTrace record (a fragment, a merged line or an empty line) is where one writer's bytes end and another's begin: it now closes the platform invocation in front of it and is never matched. Invocation markers live as long as the bytes of their entry: a rewind sets them aside and gives an entry that reads unchanged its markers back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Since agent 2.0.1473 the Windows Update and servicing watchers backfill rows with their event-log
time up to 60 min before the agent started, and the session anchor only skipped the system
timeline watcher, so session starts and durations grew by 22 min at the median.
- Shared: Constants.EventSources.SessionAnchorIneligible and IsSessionAnchorEligible are the one
rule; the agent writes the same constants as Source.
- Backend: the ingest batch (ClassifyEvents, now internal static) and the Events-table probe use it.
- Web: the session duration reads the list from shared-manifests.json.
- Recorded sessions keep their stored start; the rule applies to new ingests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitHub masks only secrets.* by itself. The bearer token Get-StorageAuthHeaders.ps1
mints at run time was never registered as a mask, unlike the Key Vault signing token,
which build-agent, build-msi and publish-scripts mask before exporting it. No caller
prints it today -- every call site captures the hashtable and passes it only as
-Headers -- but this repository's run logs are public, and a single debugging line
would have published it: an uncaptured call of the helper, or Get-Error /
Format-List on a failed request, whose TargetObject is the HttpRequestMessage
including the Authorization header (reproduced locally with a dummy token).
The helper now emits ::add-mask:: for every token it hands out, which covers all six
workflows in one place. Write-Host, never Write-Output: the output stream is the
script's return value. Verified with a mocked az that the result stays one hashtable
and that a nested caller in the Request-BuildNumber.ps1 pattern still returns only
its build number. Only under GITHUB_ACTIONS -- locally the line would print the token
instead of hiding it.
Prompted by an external AI review of the public scripts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The Members card was the only card in the edit modal with a separate
icon column: its title, add form and member list sat 32px in from the
card edge, which on a phone also took 32px from the email field and the
member rows.
- It now uses the layout of its sibling cards: the title on top, the
content at full card width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNLaZbFQTdhQJU9jh37Hn6
Since e537a4b the backend writes a new versioned file per publish, so its
immutable header holds again. The browser revalidates only the manifest
(no-cache) and serves the file it names from its cache (default mode),
instead of revalidating both, the workaround in 762fef4 for the per-day name.
The build log names the baked file ("Platform stats baked into the landing
page from platform-stats.<date>T<time>Z.json (as of ...)"), so every web
deploy shows which publish it carries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
The versioned stats file is served public, max-age=31536000, immutable, and
the manifest platform-stats.json names the current one. Its per-day name
(19e9713a) was unique while maintenance ran once a night; since 82919d47 it
runs every 2 hours and rewrote that file up to 12 times a day, so caches kept
the first copy of the day. The name now carries the publish instant to the
second in invariant culture (platform-stats.2026-10-03T060004Z.json).
Headers and access tier travel with the upload instead of two follow-up
calls, which left the blob briefly without Cache-Control and content type,
and kept it that way (with the manifest still on the old file) when one of
them failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
Crawlers and LLM fetchers run no JS and saw only the skeleton of the stats
band. app/page.tsx now awaits one read of the public stats blob during
next build (lib/platformStats.ts: manifest, then the versioned file it
names) and renders the numbers into the export. StatsBand starts from that
snapshot and replaces it with the live numbers; a failed live read keeps
the baked numbers instead of hiding the band.
The read is awaited at page level, not under Suspense: React moves a
resolved boundary of a page this size into a hidden div at the end of the
body. It uses no-store (route force-static), so Next's fetch cache cannot
hand a later build the previous numbers. Fail-soft: without a snapshot the
page ships the skeleton and loads live as before, and deploy-web.yml warns.
The browser now revalidates both files (no-cache instead of force-cache):
the backend rewrites the per-day file every 2 hours under immutable, so a
copy cached earlier that day would have made the numbers drop after load.
The page body moved to components/landing/LandingPage.tsx, which the
marketing guards render synchronously; StatsBand left the .json() ratchet
baseline; the sitemap lastmod of / includes the body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
TimeAttributionCalculator v4 cuts two segments out of the phase partition:
os_update from the OobeNDUP page start whose visit holds update evidence
(a servicing step or a name only an update in progress writes) to the last
update evidence before the user is back; awaiting_sign_in from there to the
first evidence of the user, only after a restart. Both replace the phase
span they overlap; without evidence the partition is v3's. OsUpdates names
the KBs and restarts. Reboot gaps no longer start or end on the clock-sync
row logged at boot. The sweep recomputes the last 30 days.
Aggregates carry eight segments; the session lane and Fleet Health show
"Windows Update" (cyan) and "Waiting for sign-in" (sky) with the KBs and
restarts in legend and tooltip; MCP get_time_attribution description;
manifest and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ShellCoreTracker reads CXID, result, name and value from the event properties
(62404..62407 %1/%2), live and in the backfill. The formatted message is
localized: German devices lost result and value, French devices reported no
update page at all. The text patterns stay as the fallback and accept
guillemets. The Hello-wizard start reads the CXID property too.
OobeUpdateTelemetry: page start and stop sit outside the run budget (at most
3 per page), names share a budget of 150 (was 50; the cloud page logs ~45
init names in its first 40 s). ReportPage returns whether it reported.
A RecordId watermark (shellcore-page-watermark.json) claims every update-page
record and persists reported ones, so the restart backfill does not send the
minutes before the restart again. EspAndHelloTracker passes the state
directory.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The form no longer lays out by viewport breakpoint: a wide window can host a
narrow column next to the settings sidebar, where the input shrank to a few
characters. The input now grows from an 18rem basis and wraps below the type
switch when the row has no room; role and Add wrap together.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An open member row keeps role and bootstrap permission as a draft:
Save writes it, Discard or closing the row drops it, so a stray select
change no longer alters anyone's access.
- AddMemberForm takes `stacked` for narrow hosts; the GA tenant editor
modal uses it, so the email field spans the full width there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- EntraAppRoleResolver maps the Viewer app role next to Admin and Operator
(the highest role wins); a claim Viewer gets no bootstrap-token rights.
- The tenant settings page and the GA tenant editor share components/members:
one line per member, a click opens who added it and the actions, Remove
asks in the row, role chips with counts, a search, 25 rows per page. A
vanished chip or a shrunken list moves the selection, so the list never
shows an empty page and never snaps back.
- The add form sits above the list, About Roles is collapsed, and a tenant
with Entra app roles enabled sees that those users are not listed.
- The EntraAppRolesEnabled doc names Viewer; manifest and generated wire
types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CodeQL js/incomplete-hostname-regexp (alert #96) traced DOCS_URL into the
RegExp built in the "Learn more" assertion. The dots were escaped with
[.], which CodeQL does not recognize as a sanitizer. A substring match
on the exact markup needs no escaping and keeps the same checks: a space
after the sentence, the exact href with target/rel, and "Learn more" as
the link text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitBook publishes a page under the slug of its SUMMARY.md group, not its
folder: troubleshooting/ is /troubleshooting-and-support/, trust/ is
/trust-and-security/. A folder URL works only for pages that predate the
group rename (307); for a newer page it is a 404.
- Web: the Progress Portal access hint linked the new troubleshooting
article by folder path (404 live). It now uses DOCS_PATHS.progressPortalOnly.
- MCP: docUrl built folder URLs for every search_docs citation. Live census
over all 58 SUMMARY pages: 47 direct, 10 via redirect, 1 dead.
summaryFolderSlugs now derives the segments from SUMMARY.md; all 60 page
URLs of the real corpus answer 200.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both variants end with a muted "Learn more" to
troubleshooting/progress-portal-only in the customer docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A member without a portal role only ever sees the Progress Portal and never
learned that their organization already runs the full product. Below the
search they now see one muted line: ask your Autopilot Monitor admin for a
role. When the organization holds no session and signed up more than 14 days
ago, the line names the signup date and the support mailbox instead.
- GET progress/tenant-status (AuthenticatedUser, JWT tenant only, no session
data): unused flag plus signup date. Both reads are strict, so a storage
failure is a 500 and never reads as "unused".
- ISessionRepository.HasAnySessionStrictAsync: key-only probe on
SessionsIndex with the primary-table fallback, no catch.
- ProgressTenantStatusResponse + wire parity facts; regenerated manifests and
web/MCP wire types.
- lib/landingTarget.ts: one landing decision for the landing gate and the
activation page. Leaving activation no longer sends Operators, Viewers,
Global Readers and delegated admins to the Progress Portal, and it keeps
the deep link the landing gate saved during activation.
- lib/supportContact.ts: one source for the support address.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Report 3a2207978e4c: a session was failed 30 min after the ESP exit while Windows installed its
OOBE quality update. The update watcher had been blind since July and the engine had no notion
of the phase.
- Windows Update client: 19/20/43/44 come from the System log (provider-filtered XPath), as the
provider manifest routes them; 25/26/31/41 from Operational via their own config list.
Updates are classified os/defender/store/other: only Windows/.NET updates become rows, the
rest is counted into windows_update_activity_summary. Per-channel watermarks persist a record
only after its row and engine signal were handed to the ingress.
- Servicing: CBS steps from the Setup log (windows_update_servicing), client UpdateAgentLCU.
- Engine: OsUpdateActivity and OsUpdateFacts (event-time ordered) anchor an OOBE update phase
after the ESP exit. Each state has its own bound: the update runs at most 3 h (then Failed
oobe_update_not_finished), after it the user has 60 min to sign in (then the engine stops
waiting without a verdict), after the sign-in one window of grace.
- The phase acts only in OobeUpdatePhaseMode=Active. Shadow, the default, reports what it would
decide as oobe_update_phase_shadow right before today's verdict. The agent stamps the mode on
EnrollmentFactsObserved at every start; a later stamp can take Active away, never grant it.
- Agent: a stop without a verdict ends the session like the max-lifetime watchdog
(agent_shutting_down reason=stopped_waiting); the backend classifies it honestly (verdict
origin stopwait), never Failed.
- Measurement: oobe_update_page (the update page and its ExpeditedUpdate_* names; a 62407 value
only as boolean, integer or HRESULT) and oobe_update_state (NDUP and ESP-policy registry
values), both Debug and bounded per run.
- ANALYZE-DEV-004 1.2.0 counts install and download failures only, never scan failures.
- Event types in Constants, MCP catalog and web; shared manifests regenerated; privacy page
names Windows updates installed during the enrollment.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Two code comments of the gather-rule timeline feed named a customer's domain next to the
session id; the session id stays as the regression anchor.
- The get_ime_pattern_health description and the IME Pattern Health admin text no longer send
the reader to "the IME decompile"; the IME-TOKEN-FAILURE and IME-DO-TEL pattern descriptions
say "verified against IME x" / "absent from the x builds" (rules/dist regenerated).
- Code comments that cited decompiled IME builds cite the IME version instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Logparser positions per (rule, file) and the on_change values persist in
State\gather-rule-state.json, bound to the session id. A reboot, hard kill or WhiteGlove
Part 2 no longer replays a log from byte 0 or re-emits an unchanged on_change result. A
replaced log (fingerprint of its first 4 KB) is read from the beginning. Positions are saved
after the events went out; restoring the file is fail-soft. --run-gather-rules stays in memory.
- LogLineReader: a maxLines stop loses no line, an unfinished last line is never split
(interval rules wait one run, one-shot triggers read it again next run), UTF-16/UTF-32 logs
are read by their byte order mark from any position, line ends as StreamReader.ReadLine,
logLineNumber counts from the start of the file, lines over 32 MB are skipped. A maxLines
value that is no positive number falls back to 1000.
- UserProfileResolver no longer caches "no user signed in yet" (detection retried at most
every 30 s); guarded callers expand the token with the profile they checked.
- MCP get_session_events, search_sessions_by_event and query_raw_events accept a gather
rule's own outputEventType; an empty first-page result for an uncatalogued type carries
eventTypeNote with the closest built-in types instead of being rejected up front.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The summary endpoint takes perBand: above 0 it adds bandCves and bandSoftware, each severity and
priority band's top entries outside the lists. The portal asks for 10 per band and shows them only
under a filter, so Attend lists its 7 CVEs instead of nothing; the MCP tool never asks and keeps its
size. The Most exposed controls sit below the title in every state, bands with 0 entries are
disabled, and the first load shows CalculatingDialog (the Geographic Performance dialog without its
page) with separate estimates for all tenants and one tenant. D-308.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The exposure summary groups CVE index rows by product: every new row carries the finding's
CPE vendor:product as Product, and a row without it borrows the product of a row with the
same title. The summary adds topSoftware, distinctSoftware and kevAffectedSessions, and
known-exploited CVEs and products stay listed below the topN cut (capped so the MCP result
stays under its 50k cap). The shared panel is rebuilt: KPI tiles, a severity donut and the
Act/Attend/Track ladder as list filters, and a Most exposed card with Software and CVEs
lenses. get_vulnerability_summary describes the new fields. D-307.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The community box on the gather and analyze rule pages gets a line that an
assistant connected via MCP can draft, validate and test a rule, linking the
AI-assisted rule authoring docs page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host answers /plans, /about, /privacy and the other page paths with a
301 to the slash path first. The navbar, the footer, the landing and
get-started calls to action, the terms page and two portal links to
/settings now point at the slash path directly. A guard renders every
public page with the build's trailing-slash setting and fails on an
internal link without it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New public page /ai, linked from the navbar (AI), the Then just ask section
and the footer. It shows what an AI client can answer from the structured
enrollment data: a question gallery whose answer panel switches between chat
app, VS Code, terminal and self-hosted, the data path (where the model runs,
what reaches us) and how to connect. A test keeps every MCP tool and argument
on the page in line with the server's registrations for tenant users.
The landing, /get-started and /ai send anonymous click and section-view
counts (marketing_cta_clicked, marketing_section_viewed); every link and
button carries a data-track id. Layouts with their own OpenGraph metadata
keep the shared image and the large Twitter card again. The navbar tightens
between lg and xl so the new entry fits at 1024 px. The FAQ names the
documented MCP clients, and the terminal demo uses the real search_sessions
argument (deviceName).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unlisted page /events/<slug>/ for the link in the post-event mail: try it,
request a walkthrough, read the docs, plus the free community editions of
SCEPman and MyWorkID. It is noindex and kept out of the sitemap, robots.txt
and llms.txt. Every call to action sends one anonymous click event
(event_followup_clicked). The customer quote is now shared with the landing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The navbar gets a "Star" pill with the repository's star count from sm up;
phones keep the bare GitHub mark. A new section before the closing CTA
shows one customer quote with a star ask underneath. The footer's
"Star us on GitHub" line is gone, so the page asks twice, not three times.
The count is read once per web deploy (deploy-web.yml, own step, job token
only there) and inlined by next build as NEXT_PUBLIC_GITHUB_STARS, so
visitors' browsers never contact GitHub and the CSP stays as it is.
Without a value the pill renders without a counter (D-303).
The desktop links, bell and sign-in now start at lg instead of md: the bar
already overflowed between 768 and ~1000 px and the pill widened it further.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A stored rule result keeps the rule's template; only the web and the MCP
server resolved it. The notification builders sent the explanation as
stored, so a channel card showed {{reason}}, {{appName}},
{{failedSubcategory}} and {{errorCode}} although the session had recorded
all four values.
- RuleTemplateInterpolator: the backend copy of the resolution, applied in
BuildRuleFiredAlert and AddRuleResultSections before the text is cut.
In a notification a list item whose value was not recorded is dropped
and a token in running text becomes an en dash.
- The cut lands on a line, sentence or word boundary instead of a
character count; the limits are unchanged.
- tests/fixtures/rule-template-interpolation/cases.json: one case file
run by the backend, the web and the MCP server.
- The engine's evidence auto-field list is one constant. The web, the MCP
interpolator and the validate_rule token lint gain timeoutHours, which
the engine already recorded.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: tenant Hello wait timeout up to 1 hour
The "Hello Wait Timeout" setting (30..300 s in the UI) had no effect in the
V2 agent: its only consumer, HelloTracker.StartHelloWaitTimer, has no
production caller, while the decision engine armed a fixed 300 s
hello_safety deadline after the ESP exit and the tracker ran a fixed 300 s
completion timer after the wizard start.
The setting is now the real Hello budget, clamped to 300..3600 s
(HelloWaitTimeout in AutopilotMonitor.Shared). Every value that was valid
before (<= 300, including the old 30 s default) keeps today's 5 minutes.
- Engine: the agent stamps helloWaitTimeoutSeconds on EnrollmentFactsObserved
(only when > 300), recorded set-once in EnrollmentScenarioObservations.
All six hello_safety arm sites share one builder with the tenant window;
a fact that arrives after hello_safety was armed moves it out. The WDP
backstop is never shorter than the tenant window; the signal census
surfaces hello_wait_extended.
- Agent: the Hello tracker's wizard completion timer uses the same budget;
the stall probe skips session_stalled while hello_safety is pending.
- Backend: ValidateModel rejects values outside 300..3600; stored rows are
read as their effective value, so legacy 30 s rows never block unrelated
saves or backup reverts. Default 30 -> 300; shared manifest and wire types
regenerated.
- Web: input 300..3600, bounds pinned against the C# constants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG
* fix: keep the Hello wait's 30 s minimum and default, raise only the maximum
Product decision: the setting keeps its UI contract (30 s minimum and
default, same description); only the maximum moves to 3600 s. Configured
values up to 300 s keep the built-in 5-minute Hello window, larger values
extend it.
- HelloWaitTimeout: MinSeconds/DefaultSeconds 30; BuiltInSeconds 300 is the
floor of EffectiveSeconds.
- Backend validates only a changed value (30..3600), like the retention cap;
the read-time normalization is gone again.
- The agent stamps the tenant window only above BuiltInSeconds.
- Web keeps the original text and default; only the maximum and its hint
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG
* fix: Hello wait default 300 s, stale-fire guard, deferred stall report
Product decision: default 300 s (what V2 has always waited), minimum 30 s
still accepted, maximum 3600 s. Values up to 300 s keep the built-in
5-minute window, which the settings hint now says.
Review follow-ups:
- Engine: a hello_safety fire whose incarnation was replaced (armed due
later than the fire's due time) is dead-ended, same guard as RealmJoin —
a late tenant window could otherwise be undercut by the queued fire of
the built-in deadline. The tenant fact is recorded only when it extends
the built-in window.
- Stall probe: session_stalled is held back while a tenant-extended Hello
wait (hello_safety or the WDP backstop) is pending and emitted once it is
over and the session is still idle; real activity drops it. Default
tenants never hit the guard.
- Comments: WDP backstop wording, 5-min reference, AgentConfiguration.
- Web pins the default against the shared manifest; tracker constructor
defaults use HelloWaitTimeout.DefaultSeconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG
---------
Co-authored-by: Claude <noreply@anthropic.com>
Every visit by a user without a tenant role joined the tenant broadcast
group before the dashboard redirected to /progress, and the settings
area loaded the bootstrap session list for every role. The backend
refused both by rule: 134 AddToGroup 403s, 19 serial-less session
joins and 27 bootstrap-list 403s in 30 days, each one also an
api_request_failed event.
- lib/signalrGroupAccess mirrors the backend's same-tenant join rules
(broadcast and member-notify groups need an own role or platform
scope, the admin-notify group the Admin or a GA, a session group a
serial for roleless callers, global-admins platform scope, an
unparseable name is a 400). Cross-tenant joins stay with the
backend's platform/delegated admission. The gate keys on
hasOwnTenantOrPlatformRole, not hasTenantReadScope: a delegated admin
without an own role is refused the home tenant's groups too.
- SignalRContext.joinGroup applies it before sending: a refused join
reaches onDenied with the server's status and is never held for the
reconnect rejoin. joinGroup depends on the caller's role fields as
primitives, so its identity changes only when auth/me resolves, the
same commit that delivers the tenant id consumers join with. That
also ends the "tenant-" join the dashboard sent before the tenant id
was known (the 400s).
- TenantConfigContext loads bootstrap sessions only for
canManageBootstrapSessions (mirror of BootstrapManagerOrGA: GA, Admin,
Operator with the bootstrap grant).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under D-267 a managed tenant saw every operator-provisioned access as
"Platform support". A customer in two operator groups run by different teams
got two identical entries under "Who can read this tenant" and could not tell
them apart.
A Global Admin can now give an operator group a customer label (at most 80
characters) with the existing PATCH global/tenant-groups/{groupId}, which takes
only the fields to change: name and/or customerLabel, where an empty label
clears it. The customer sees the label as the entry name; a group without one
and direct grants keep "Platform support". Owned (self-service) groups refuse a
label with 409, because their customers see the managing tenant's name.
RenameTenantGroupAsync becomes UpdateTenantGroupAsync, one read-modify-write on
the meta row.
DelegationSelfService.CustomerNameOf is now the one source of the name a
customer sees for a group. The managers view and the Group detail of the audit
rows written under every member tenant (assign, unassign, group delete) both use
it. Those audit rows carried the internal group name to the customer's audit
page before this change.
Admin, Tenant Groups: operator groups show "Customers see: ..." and get a
"Customer label" button next to Rename. The customer page is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WhiteGlove seal: IME reports "EspPhase: AccountSetup" from the device session
as soon as the device apps are done when the user ESP is enabled. The sealing
classifier counted that as user activity (-40) and left pre-provisioning Part 1
unsealed, so no whiteglove.complete marker was written and the Part-2 boot hit
the 48-hour emergency break. The excluder now also needs the ESP registry to
show AccountSetup progress: the adapter stamps accountSetupProgress on
WhiteGloveShellCoreSuccess, only after the tracker has actually read the
AccountSetup subcategories (unread is never "untouched"). The new excluder is a
subset of the old one, so a verdict can only rise; signals without the key
score as before. WhiteGloveShellCoreSuccess v1 gets its replay fixture.
The Part-1 exit writes whiteglove.complete as its first step instead of after
analyzers, diagnostics upload, grace and drain, where a reseal could kill the
process before it.
Registration failures: a run that exits because register-session failed
records outcome, last error, network link at start and end, the wall-clock
attempt window and the config-fetch outcome in registration-failure.json. The
emergency break sends the record as AgentErrorReport.PriorRegistrationFailure
(null for older agents); the backend bounds it and adds it to the ops details,
the App Insights dimensions and the timeline data. Retry behaviour is unchanged.
The raw registry trigger whiteglove_signal_* is renamed save_result_step_*: the
step exists in every 25H2 enrollment and the old name read as a
pre-provisioning signal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The agent measured the session age at its 48 h break but never sent it.
AgentErrorReport now carries SessionAgeHours (optional, null for older
agents), and the backend puts it into the ops details, the timeline data and
the App Insights dimension. It is information only: the backend cannot derive
it (no row, or a row registered seconds before the break after a hibernate
resume), and it never feeds the severity.
The backend now reads the Sessions row once for the owner binding and the
break verdict. A report for a session without a row (in practice one that
never registered) is Info and writes no timeline event and no EventType index
row: nothing can close or open that session, and the orphan sweep deleted the
event a day later with a Warning of its own. A row under cascade delete gets
nothing written past the lock. Terminal stays Info with lateCleanup; an open
status or a failed read stays Warning with the historical write. Break
materialization now requires GUID ids.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each Sessions row is bound to the device identity that registered it. The
binding ran in shadow; it now refuses every foreign-identity outcome:
register-session answers 403 with session_owner_mismatch before anything is
written, agent telemetry 403 in the error envelope (SessionOwnerMismatch),
the hosted diagnostics upload URL 403. The error channel stays 200 and only
skips its write into the named session. An evaluation defect fails open.
The agent already rotates its session id on the 403 at registration; it now
does the same for the coded 409 (session id claimed by another tenant) instead
of retrying six times and exiting, and fails without retry on any other coded
409 (BackendRefusalException, never an auth failure). A snapshot restored
after a rotation adopts the current session id.
A guard test pins the four call sites and fails when a site observes the
binding without acting on the refusal. Privacy page: session binding under
how data is protected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The audit log, ops events, rule submissions and session reports each
carried a copy of the same paging state. Next bumped the page number and
cursor stack before the request and kept them when it failed, so a failed
request showed "Page 3" over the rows of page 1.
useCursorPager (pure transitions in lib/cursorPager) owns rows, cursors
and the stack; the page number is derived from the stack. A move commits
only after its page loaded, and only the latest move commits. The fetch is
read through useLatest, which retires the per-page workarounds for the
token getter's changing identity.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cross-partition pages (audit log over all tenants, ops events over
all categories) kept one continuation entry per partition that still had
rows. On the tenant fan-out the nextLink outgrew the host's URL limit:
the follow-up request was rejected with HTTP 414 before the function ran,
so Next never loaded a second page for a Global Admin.
The continuation is now the (RowKey, Partition) of the last returned row.
Every page reads each partition through BoundedFanOut (pageSize + 1 rows
after the watermark) and merges by (RowKey, Partition), which is the
display order in both tables. Partitions after the watermark's partition
continue with `RowKey ge`, the others with `RowKey gt`, so equal ops
RowKeys across categories survive a page cut. The merge no longer sorts
by the resolved OccurredUtc while advancing by RowKey, which could skip
rows where the two disagree.
The fan-out was unbounded (Task.WhenAll over every tenant); it now runs
with the shared cross-tenant concurrency ceiling like the session list.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
App install rows now carry their install channel. The ingest folds the RealmJoin
watcher's package started/completed events into AppInstallSummaries rows (channel
"realmjoin", keyed by scope + package id; duration = completion minus the observed
start). IME rows keep their key and have no channel column, so they read as before.
Every per-app aggregate groups on (channel, name): apps list, app analytics and
sessions (new ?source=, default ime), metrics/app, SLA top failing apps and the
version-regression radar (tracker key, episode key, bell link, ops event). The app
install SLA rate includes RealmJoin packages. The ESP-blocking stamp stays IME-only.
RealmJoin packages do not feed the software inventory or CVE correlation.
Web: a RealmJoin pill and an All / Intune / RealmJoin filter on Software > Installs,
the pill on app detail, Fleet Health and SLA, source-aware app links and PDF meta
line, and a hint in the empty Inventory tab. The pill moves out of InstallProgress
into a shared component. MCP: get_app_install_metrics names the source field.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The platform forwards whatever certificate the TLS client presented, so an
unauthenticated caller can hand in a leaf signed by a CA it made up. The
chain engine then fetched the leaf's AIA caIssuers URL before rejecting it:
an attacker-directed GET from the backend, and a worker blocked for the
10 s retrieval timeout. Every legitimate issuer is embedded and Intune
certificates carry no AIA, so downloads are now disabled and the unused
retrieval timeout is gone.
The regression test signs a leaf with a CA that is neither embedded nor
sent and asserts that the listener behind its AIA URL sees no connection;
a self-signed leaf never reaches that path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.
- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
read with ETag, tombstone refused, mutation decides, conditional replace, conditional
insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
Regenerated shared manifest and wire types (web, MCP).
Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).
Bumps internal (D-290, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Offboarding deletes the tenant configuration, and the returning tenant's first login recreated
it with TrialConsumed=false, so the one-time 30-day Pro trial could be chained by offboarding
and re-onboarding.
- The offboard click reads the configuration fail-loud and ORs TrialConsumed into the
OffboardingByTenantPointer (sticky across rounds, same ETag CAS as OffboardCount).
- The first-login write (HandleNewTenantDomainAsync, the one write that sets OnboardedBy)
carries it onto the new row; an unreadable pointer means no write, retried next login.
Re-onboarding itself is unchanged, auto-approve included.
- TenantConfigurationService.UpdateAsync: fresh projected row with ETag, offboarding
tombstone refused (403 TenantSuspended), mutation may decline, conditional replace with
the ordinary pre-write backup, re-read and re-decide on a lost race (409 after 3).
SetTenantPlanTier and StartTrial use it, so a warm instance's cached row is never saved
(it could lift the tombstone during the drain barrier) and two concurrent trial starts
cannot both pass.
- Tests: TenantConfigUpdateTests (service + both endpoints), pointer stickiness and the
offboard endpoint in TenantOffboardFunctionTests, first-login carry-over in
AuthFunctionSideEffectTests; shared EndpointHarness.
Bumps internal (D-288, D-289, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SsrfGuard.ValidateDestinationAsync resolved the webhook host and checked
it, then HttpClient resolved it again for the POST, so a DNS-rebinding
host could pass the check and be connected to an internal address.
The webhook client's SocketsHttpHandler now connects through
SsrfGuard.ConnectToPublicAddress: it resolves once per connection,
refuses the connection if any address is blocked, enforces HTTPS and
connects the socket only to the checked addresses; TLS still runs against
the request host. The pre-flight check is removed, the registration moves
to AddWebhookNotificationHttpClient so a test can prove the wiring, the
notification retry policy no longer retries a refused destination, and
the test endpoint reports the refusal with the same message as before.
IsBlockedAddress follows the same model as the MCP server: all IANA
special-purpose space, IPv6 only 2000::/3, zone ids and IPv4-mapped
addresses refused (IPNetwork.Contains judges a mapped address by its
IPv4 form, so those are refused first).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The SSRF gate resolved the client_id host, checked the answer and then
handed the hostname to fetch(), which resolved it again at connect time.
A DNS-rebinding host could pass the check with a public answer and be
connected to an internal one.
The metadata fetch now runs through a dedicated undici dispatcher whose
connect.lookup is the gate: one resolution per connection, every address
must be public, the socket connects only to those addresses. The global
fetch is untouched.
The address classifier now refuses every IANA special-purpose block
(IPv4), allows only global unicast 2000::/3 minus its special-purpose
blocks (IPv6), and refuses zone ids and IPv4-mapped addresses. The tests
enumerate the unedited IANA registries (tests/fixtures/
iana-special-purpose-addresses) and prove on loopback sockets that a
refused host opens no socket and that an allowed one is connected to
exactly the address the gate returned.
Adds undici ^8.11.2 (engines >=22.19.0).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When ipinfo.io (plus retry) and ifconfig.co both fail, the agent now asks
geo.prod.do.dsp.mp.microsoft.com/geo. It returns only CountryCode and
ExternalIpAddress, but it belongs to the DO/Windows Update endpoint set Autopilot
needs anyway, so it stays reachable where public geo services are blocked.
The session then gets a country-only device_location (source "do-geo", no
timezone, so timezone auto-set stays pending) instead of none.
- GeoLocationService: the three providers share one TryProvider skeleton;
response parsers are internal and unit-tested; GeoLocationResult.Describe()
skips missing parts ("DE" instead of ", , DE") in the event message and log.
- Failure trace carries doGeoError + lastResortProvider.
- Backend: geographic location keys skip empty region/city, so country-only
sessions group as "DE" rather than ", DE"; drilldown still round-trips.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ClientAppNotRegistered refusal (D-279, now enforced) sends Tenant Admins
to this form; its hint required access_as_application, which an app acting
on behalf of users does not need.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each save handler still builds the edited configuration but now sends changedAdminConfigFields(loaded, edited) through lib/adminConfigSave.ts, and nothing at all when nothing changed. This covers the Global Settings, the diagnostics paths, the ops alerts and the vulnerability section. The MCP access policy patches its single field without reading first. A page loaded before another writer (the agent release pipeline, a second operator, a sync job) can no longer revert that writer's fields. Deployed after the backend that serves PATCH global/config.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On 2026-09-25 a Global Settings save from a page loaded before an agent release wrote the whole configuration back and reverted the release's LatestAgentV2* hashes. After that, every 2.0.1465 agent reported IntegrityCheckFailed. The flaw was structural: every writer (portal PUT, plan tiers, three vulnerability-sync stamp paths) replaced the whole row from the copy it held, usually the 5-minute cache.
- IConfigRepository.UpdateAdminConfigurationAsync(mutate, updatedBy) is the one write path. It reads the row fresh with its ETag and applies the mutation to a copy materialized from that read. It computes the changed columns from the converter projections and writes only those onto the freshly read raw entity; a null value drops the column. The replace is conditional, and a conflict triggers a re-read and retry. A no-op writes nothing. Pipeline-owned and unknown columns survive. The full-model SaveAdminConfigurationAsync / SaveConfigurationAsync are removed, and the first row is created with AddEntity.
- PATCH global/config with { fields } (PatchAdminConfigurationRequest) replaces PUT/POST. Its field gate is derived from the persisted columns and rejects server stamps, LatestAgentV2* / LatestBootstrapV2ScriptVersion, PlanTierDefinitionsJson, the sync stamps and redacted placeholders. The route catalog lists PATCH.
- Plan tiers, TriggerMsrcSync, TriggerVulnerabilityDataSync, VulnerabilityDataSyncFunction and VulnerabilityCacheRefreshService.StampAsync each hand in a mutation.
- Tests: AdminConfigurationChangedFieldsTests (stale-page toggle keeps the release hashes, concurrent pipeline write re-read and kept, no-op, rejection, null drop, first row, gate); existing tests adapted; the request guard allows the admin field map.
- Regenerated shared manifest and wire types (web utils + MCP). The web still sends PUT until the web commit that follows the backend deploy.
- The internal pointer is bumped to D-285.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A portal-registered client (amc_) now receives its Entra refresh token only as a compact JWE (RFC 7516, dir + A256GCM, kid amc-rt-v1). The key is derived from OAuthStateSigningKey. At refresh, the registration comes from the envelope, never from the client_id parameter. Before, a copy of the token redeemed without client_id went to the organizations authority and skipped the registration check.
A raw refresh token presented with an amc_ client id is accepted and returned sealed, so these clients migrate on their next refresh. All other clients are unchanged. jose becomes a production dependency; it was a dev-only transitive dependency before. The internal pointer is bumped to D-283/D-284.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The option is a regular tenant-admin toggle in its own "Without pre-registration" block, with the
add-on permission state read from the existing graph-permissions status endpoint. The dashboard
banner and the admin Ready badge key on "any validation method enabled" (feature flag
deviceValidationEnabled, manifest-driven validate* fields) instead of Autopilot alone, so a
device-preparation tenant that validates by device association is no longer told that agent
ingestion is blocked. Stale "shadow mode" wording for Device Association is gone, the
Not-registered report names every method, sessions show "Intune Enrollment", and the privacy
page and FAQ list the new method. Grant script help describes the IntuneDeviceBinding feature
as Intune Enrollment Validation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The certificate's Intune managedDevice id is looked up in the tenant's Intune. With the tenant
switch ValidateIntuneDeviceBinding on, that lookup is the last accepting link of the validator
chain: an enrolled device that is not being retired, wiped or deleted is admitted as
ValidatorType.IntuneEnrollment. A 404 for a certificate younger than 60 minutes is
NotFoundRecentEnrollment (503, never cached); a missing grant is 503 with 120 s.
For devices another validator admitted, the lookup only observes: in the background, never
admitting or rejecting, and only when the app token carries
DeviceManagementManagedDevices.Read.All, so a tenant without the add-on costs no Graph call.
Outcomes ride on the request row (CertDeviceBinding, role, serial match, owner and enrollment
type).
TenantConfiguration.HasAnyDeviceValidation() is the single gate precondition; the feature flags
expose it as deviceValidationEnabled (replaces validateAutopilotDevice).
The agent parses ValidatorType strictly: release the agent before this backend.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The registrations (name, callback URL, client id) live in their own table,
not in TenantConfiguration, so the report only showed the limit. Fetch
GET tenants/{tenantId}/mcp-client-registrations fail-soft alongside the
other side views and render them in the Tenant Status section, plus the
platform switch. The limit row is relabelled to say it is a limit.
Claude-Session: https://claude.ai/code/session_01DbRs7NCppizQ7wWFjN7n3M
Co-authored-by: Claude <noreply@anthropic.com>
The query recognizes only npm limiter packages, never oauthRateLimit /
oauthTokenRateLimit, and flags a handler as soon as it calls a function
named like *auth*, *login* or *verify*. All six alerts it ever raised were
on throttled OAuth routes (the last two came from tenantAuthority()).
What it stood in for is now a test: every route of the OAuth router must
run a per-IP limiter first unless it is one of the three named discovery
documents, /oauth/token must carry the strict budget, and a router.use()
middleware fails the check. Verified by mutation: removing the limiter
from /oauth/authorize fails the test with the route name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The registration lookup put a client_id-derived value into the backend URL
path unencoded. The amc_ regex already limits it to 32 hex characters, so
nothing changes at runtime, but every other request-derived path segment in
the MCP server goes through encodeURIComponent, and CodeQL counts only that
(or a ?/# prefix) as a request-forgery barrier - a regex capture group is
not one. Closes code-scanning alert js/request-forgery on oauth.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A ?serial= parameter (serial number or exact device name) runs one lookup
as soon as sign-in has resolved the tenant, so organisations can send users
a link to follow their own device. Every hit writes the found session's
serial back into the URL for reload, bookmark and copying. Authorization is
unchanged: sign-in stays mandatory and roleless callers still need the exact
serial server-side. The query survives the login redirect because
ProtectedRoute keeps pathname + search. progress_serial_submitted carries
source form | link.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Settings > Tenant > AI Integration (between SLA Targets and Bootstrap
Sessions) explains the normal connection first - server URL for Claude,
ChatGPT, VS Code and other hosted assistants, nothing to register - and
shows the self-hosted client registration below it (Tenant Admin, platform
switch on). The page is visible to admins, platform scopes and anyone with
MCP access. The self-hosted example text is neutral.
- A tenant may hold one self-hosted client registration. The GA-only
TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request:
reverted for non-GA callers on PUT, refused on PATCH, edited in the Global
Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A self-hosted AI client redirects to its own domain, which the vendor
redirect allowlist never admits, so it could not use the browser sign-in.
Tenant Admins can now register the client's exact OAuth callback in the
portal and configure the client with client id amc_<registrationId>.
- MCP OAuth proxy: an amc_ id is resolved through the backend; its
registration is the redirect allowlist entry for that exact callback.
Authorize and token run at the registering tenant's Entra authority, and
the token endpoint discards a token of any other tenant (code and
refresh). Lookups are cached for 60 s, a failed lookup fails closed. Every
other client keeps the allowlist check before its resolution.
- Backend: table McpClientRegistrations, TenantAdminOrGA routes
tenants/{tenantId}/mcp-client-registrations (list, create, delete; at
most three, https or loopback http, exact URI, audited) and the anonymous,
per-IP rate-limited lookup auth/mcp/client-registrations/{id} (under
/api/auth for the client-certificate exclusion list).
- AdminConfiguration.McpClientRegistrationEnabled (default off) is the
platform switch: off hides the portal section and makes every lookup 404.
auth/me carries it for the sidebar.
- Portal: Settings > Tenant > Self-hosted AI clients (Tenant Admin / Global
Admin), and the switch in Admin > Settings > Global.
- Tests: MCP tenant-client flow (authority, tid check, revocation, cache,
allowlist), backend round trip, wire parity, validation, cap, switch,
audit; nav visibility. Manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy issues no client secrets, but its authorization server metadata
named no token_endpoint_auth_methods_supported (RFC 8414 then implies
client_secret_basic) and /oauth/register echoed a requested secret-based
method. A client following that default registered with client_secret_basic
and then failed the token exchange ("client_secret_basic authentication
requires a client_secret"), reproduced with the SDK calls a self-hosted chat
client makes. The metadata now lists ["none"] and registration always answers
none (RFC 7591 lets the server replace a requested value).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A user token obtained by an application other than the platform's own
registrations (for example a self-hosted AI client using on-behalf-of) was
accepted with the user's full role; only audience, issuer, signature and
lifetime were checked. Such tokens are now classified on every request.
- ClientAppBindingMiddleware (after authentication, before policy enforcement)
records the request dimensions ClientAppBinding (registered | unregistered |
unidentified) and ClientAppId, plus a throttled Warning.
- AdminConfiguration.EnforceClientAppBinding (default off; Admin > Settings >
Global): when on, a foreign client is refused with 403 ClientAppNotRegistered
unless the application is an enabled member of the caller's tenant (the
app:<client-id> row a service principal uses). An admitted caller keeps their
identity but gets the application caps: at most Viewer, no platform role,
DelegatedReader on managed tenants, roleless tiers closed except auth/mcp.
- AdminIdentity.IsCapped (application or admitted foreign client) is what the
global, delegated and member-role resolvers switch on; RequestContext carries it.
- Own clients = the audience trust set plus EntraId:TrustedClientAppIds, a
client-only trust for the local development registration that never widens
the accepted audiences.
- Tests: classification, unforgeable cap marker, middleware in both modes, caps
through the real policy middleware, pipeline order; manifests and wire types
regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A backend 401 on `initialize` logged only the UPN, and the backend's own trace
("IDX10214 audience validation failed") never names the token; a registration
rejection logged only a count of redirect_uris. Diagnosing a self-hosted client
(LibreChat) that first failed DCR on its own callback host and then presented
tokens minted for another resource took a customer round trip.
- access-guard: `invalid-token-claims` and `backend-rejected-token` carry
`aud=... azp=... tid=...` (unverified claims; control characters stripped, capped)
- oauth/register: the rejection names origin + path of up to three rejected
redirect_uris (never the query); the error_description names the actual
defaults (VS Code, not Google)
- tests for both lines; skill catalog in .claude/commands (mcp-telemetry) updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Route payloads (__next.*.txt, index.txt), static JSON and the router's HEAD
self-requests were 20k of 62k dependency rows per week and nobody reads them.
lib/webTelemetryExclusions.ts builds the patterns (own origins minus /api/,
plus relative non-API URLs) for two layers: excludeRequestFromAutoTracking-
Patterns for string fetches, and a dependency initializer that drops the
finished item by its absolute target - the SDK's own check sees an empty URL
for the URL objects Next's router passes to fetch (verified against the ingest
batch: 11 such fetches, 0 shipped; API, SignalR and token rows still shipped).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P1 - full loads
- staticwebapp.config.json: /_next/static/* immutable, /landing/* max-age=86400
(SWA default was max-age=30 for hashed assets); guard test pins it
- components/NavLink.tsx: prefetch on hover/focus/touch for the portal chrome
(prefetch={false} also disabled the hover prefetch); landing links stay off
- app/layout.tsx: preconnect to the API origin
- lib/dashboardSeed.ts + lib/prefetchSeeds.ts: the dashboard's first list and
stats requests start with auth/me and are handed to the hooks as one-shot
seeds (the own-tenant list URL carries no tenantId; the backend reads the JWT)
- lib/msalConfig.ts: the login request carries the API scope, so one token
redemption serves the first API call; Graph consent via extraScopesToConsent
P2 - chatter per navigation
- lib/signalrGroupRegistry.ts + contexts/SignalRContext.tsx: group membership
is reference-counted with a 2 s grace leave; reconnect rejoins run in
parallel; on/off/invoke and the context value are memoised; the dashboard's
unconditional leave no longer drops the notification bell's global-admins
membership; consumers pair every join with a cleanup leave
- app/sessions/hooks: both group joins in parallel, the join catch-up is
dropped while the initial walk is in flight, identical session deltas skip
the state update
- lib/cachedAuthFetch.ts: TTL cache for feature flags, tenant list and latest
versions with invalidation on every write path; FeedbackBubble defers its
status call
P3 - pure derivations only (D-274): memoised filter arrays, one-pass phase
summary, status counts, useDeferredValue on both search inputs
P4 - landing
- lib/authHint.ts + AuthGate + globals.css: the loading overlay is applied by
an inline head script only when the site root is opened by a browser with an
auth hint; the prerendered HTML carries no overlay; CSS lifts it after 8 s
- hero as WebP srcset (81 KB instead of a 180 KB PNG), dead landing-v1 CSS
removed, terminal demo pauses off-screen, duplicate twitter image dropped
Telemetry (D-276)
- lib/appInsightsRuntime.ts: the SDK is its own chunk, deferred until idle on
the public host, remote config fetch off (blkCdnCfg), initial trackPageView
on both hosts, Web Vitals as web_vital_* custom metrics
Verified: tsc, eslint --max-warnings 0, vitest 127 files / 1442 tests, next
build, Playwright click-through of every portal route, settings section,
session views and public pages on the production bundle against the live API.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A platform script that ran 10 s was shown as "1h 00m" (session 4377911b): its
start line was self-anchored (+120 min) while its result line, re-read after an
overwrite rewind and therefore never fresh, fell back to the agent's cached zone
(+60 min), was rejected as ahead of the clock, and the duration was taken from
the raw pair. The same shape fired script_timeout_suspected daily in one tenant
on 2-9 s scripts read as 17 h. Fleet 2026-09-20..23: about 1 % of script
durations, 28 of 28 inflated ones carrying derivedTimestamp.
Agent: ScriptExecutionState binds the provenance of its start, result and exit
lines (CmTraceLineProvenance, persisted); EmitScriptCompleted judges the pair -
verified (both measured, or both assumed with the same offset from the same
file), unverified, or suppressed with durationSuppressedReason
(rejected-endpoint | offset-grid, CmTraceOffsetCalibrator.IsOnOffsetGrid) - and
always emits durationProvenance plus startSourceOffset*. A held completion's
sourceOffset* tags now describe its own result line; script_timeout_suspected
only judges a measurable duration; the emitted-run marker is capped at the
clock so a future-shifted result no longer swallows the next run's start.
Web: the reducer applies the same rule to stored events from older agents,
pairing a final with the latest script_started of its policy, and the panel
shows "n/a" with the explanation where the run time would be.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ImeRegistryAppStateObserver reads a fourth IME registry surface,
Win32Apps\OperationalState\<userGuid>\<appGuid>: the install deadline a
downloaded app waits for (ExecutionDeadlineTime, IME 1.106) and the
app-in-use deferral counters (DeferralsUsed, DeferUntilTime,
DeferralMaxDeferrals, DeferralAutoDeferred, IME 1.105). Both travel on the
existing registry_app_state event as data (installDeadlineUtc, deferralsUsed,
deferralMaxDeferrals, deferUntilUtc, deferralAutoDeferred) with the change
triggers installDeadline / deferralsUsed / deferUntil, and the message says
the state in words ("downloaded, install deadline ... UTC", "deferred by user
1/3 until ... UTC", "auto-deferred ..."). No new event type.
ClassifyStatusServiceStatus maps IME's legacy AppInstallStatus explicitly:
1000/1002/1004 installed, 1001/1003 installing, 2000 band notApplicable,
3000 failed, anything else unjudged. The 1000 band was treated as installed,
but IME maps every InProgress enforcement state to 1001, so a running app
counted as terminal success on the registry rail (observability only).
Tests: explicit status mapping, both registry timestamp shapes, the new
change triggers, the wait-state text, and the tick flow for a deadline and a
deferral event. Mutation proof: classifier and trigger broken on purpose
turn exactly the five expected tests red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ajv-cli 5.0.0 is the latest release and still declares fast-json-patch ^2.0.0,
so neither Dependabot nor npm audit fix can move past CVE-2021-4279. An npm
override forces 3.1.1 into the lockfile; the 3.x API is backward compatible and
the validate command used in combine-rules.yml never touches the package.
Verified: npm ci from the lockfile plus all four ajv validate steps pass locally.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The offboard endpoint tombstones the tenant (Disabled=true, "Offboarding in
progress") before it answers 202, so the departing admin submits the farewell
feedback from an already-suspended tenant. Since the suspension gate moved onto
the shared authorization path (43ddc601, 2026-08-29) every feedback POST was
403 TenantSuspended — invisible, because nobody clicked and the two stored
feedbacks predate the gate (D-270).
- Endpoint catalog: `AllowedDuringOffboarding` on exactly
POST tenants/{tenantId}/offboard/feedback; the middleware ignores the
offboarding tombstone for that route only. An operator suspension still
denies; policy tier, cross-tenant check and the function's active-marker
check are untouched. Middleware tests for all four cases plus a catalog
ratchet pinning the flag to the one route.
- Farewell mail: IOffboardFarewellEmailSender.SendAsync returns whether the
provider accepted; the offboarding worker records FarewellEmailSent /
FarewellEmailSkipped / FarewellEmailFailed like the welcome mail — the
send's own success log is Information and never reaches Application
Insights. Missing Email:ApiKey logs Warning. Handler tests per outcome;
OpsAlertRules catalog and the generated manifests (Web + MCP vocabulary)
carry the three types.
- Web: auth/me 403 TenantSuspended no longer shows alert() and forces a
logout. AuthContext keeps the user signed in with claims-only info and
ProtectedRoute renders a suspended page: for the offboarding tombstone
"Your organization is being offboarded" with the farewell feedback form
(a reload during the 6-minute preparation window keeps the form), for any
other suspension the reason text plus contact links; both with sign-out.
The feedback widget moved from OffboardingSection into
components/OffboardingFeedbackForm.tsx.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every tool description and both role instructions now fit the 2048-char host cap
(Claude Code truncated the Global Admin instructions and two descriptions silently).
Pagination, projection and scope mechanics are said once in the instructions and
the shared pageSize/continuation/tenantId/days texts; role markers left tools only
that role can see; get_resource lists its sections on the argument. The SDK $schema
URL is stripped from tools/list.
Measured (compact tools/list): Global Admin 111.4 KB -> 89.1 KB, Reader 94.7 -> 74.3,
tenant user 64.5 -> 51.3, delegated 75.1 -> 55.6 KB. catalog-budget.test.ts pins the
new ceilings, the variant counts and the empty over-cap list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
list_tenants named a real customer domain as its example; the filterTenantList test
carried three real tenants. Both now use synthetic names and GUIDs.
catalog-budget.test.ts pins the per-role wire catalog as a ratchet (size, repeated
argument texts, no foreign domains or GUIDs, the 2048-char host truncation cap with
the four known over-cap texts listed). scripts/audit-catalog.ts dumps and sizes the
catalog per role for before/after measurements.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
zizmor (static analysis for GitHub Actions) now runs twice: locally before a
workflow commit (hard rule in .claude/CLAUDE.md) and as the `zizmor` job next to
the pin gate in action-pins.yml (zizmorcore/zizmor-action pinned by SHA, zizmor
1.30.1 pinned, SARIF into Code scanning, severity low and above). Like the pin
gate it reports and does not block. zizmor.yml holds the single reasoned ignore.
Baseline fixes so the gate starts green:
- persist-credentials: false on every checkout except combine-rules.yml, the one
job that pushes with the checkout credentials (ignored in zizmor.yml, with reason)
- publish-scripts.yml: id-token: write moves from the workflow to the publish job;
indexnow-notify.yml: permissions: {}
- deploy-mcp.yml: env values referenced as shell variables inside bash steps
instead of template expansion
- pester.yml / publish-scripts.yml: reusable workflow via the self-repository form
$/ (GitHub changelog 2026-07-30); the pin gate treats $/ like ./
- combine-rules.yml: ajv-cli installed with npm ci from rules/scripts/package-lock.json
(no ts-node peer, no lifecycle scripts) instead of an ad-hoc global install;
Dependabot watches /rules/scripts
- azure/login pin comment corrected to the tag the SHA actually is (v3.0.2; v3
moved on to v3.1.0), no runtime change
- Dependabot: 7-day cooldown on every ecosystem (security updates are exempt)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The XML summary added on TenantManagerItem.Name in ef280b7b rides into
shared-manifests.json as the field's doc; CI run 35701895623 caught the drift.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET delegations/managers no longer exposes the name of an operator-created
Tenant Group to the customer; both operator shapes (groups and direct grants)
carry the neutral label. Assignees, reader count and source stay visible.
Web badge reads "Provisioned by operators". D-267.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
network_state_change and network_connectivity_check join the non-activity
list next to power_state_change. A flapping dock or access point kept the
idle-stopped periodic collectors running for the whole episode and reset
the stall clock of the enrollment it was stalling. The events are emitted
unchanged; the device-info network snapshots stay activity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IME re-runs remediation policies on its own schedule for as long as the
device is up, so an enrollment that waits for its user reported every run
of every policy. The tracker now emits a policy's first run and every run
whose result differs from the last reported one (compliance, exit code,
stderr, remediation status, error code); repeats are counted and surface
as one script_recurrence_summary per policy when the tracker stops.
The start signal of a known policy is held until its result is known,
because the running indicators pair a start with a later result; a start
without any result is still emitted after a grace. The counters live in
the tracker state file next to the file positions, so a hard kill rolls
both back together. Platform scripts are untouched. The summary does not
count as enrollment activity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stored EventCount doubles on replayed upload batches and was only
corrected at terminal transitions, so the sweep judged running sessions on
an inflated number. It now recounts a candidate before it records the ops
event or blocks the device, skips candidates it cannot verify, and limits
the recount to candidates with a pending action. The warn text no longer
claims an agent loop bug.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
73 sites in 68 files built the query collection with
HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed
both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET
Core request type does not override it, so the change is behaviour-neutral.
- QueryParamGuardTests: ParseQueryString in the Functions project is limited to a
named baseline of three sites that parse something other than the request query.
- A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded
values, caching) so a worker update that changes the implementation fails a test.
- Test mocks that set Url answer Query through CallBase; a loose mock returns null.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six functions wrote their responses through a private wrapper taking the body as
object, which no typed-response guard could see. Their error bodies carried the code
in the error slot, no correlation id, and RestoreRow returned the raw exception text
on a 500.
- Backup job status, row restore and backup trigger answer through ResponseHelper:
errors are the envelope, the 500 is sanitized, the 202 body is BackupTriggerResponse.
- Same for the session-deletion maintenance trigger, RestoreSession's three error
sites and the delegation acknowledgements (MessageResponse).
- Backup codes are constants (Constants.BackupErrorCodes) and part of apiErrorCodes;
RunAlreadyActive and EnqueueFailed join ApiErrorCodes.
- TypedResponseGuardTests gains guard F: no HttpResponseData-returning method with an
object parameter. BackupWireParityTests pins that the move from the backup
serializer options to the worker pipeline leaves the wire unchanged.
- Web reads BackupTriggerResponse from the generated wire types.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A version whose installer was already in the archive could only turn
Archived through the 409 on the write-once upload, which needs a
distribution host that still serves the build. For a version no host
serves any more the row stayed Failed:VersionMismatch next to its own
blob and re-walked every host once a day.
The archiver now checks the archive first: an entry whose ProductVersion
is the observed version ends the job as Archived with the hash and size
of the archived bytes and no download; a missing provenance sidecar is
written without a source URL. An entry holding another build, or an
unreadable one, is not accepted and the walk runs as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The browser loads the photo straight from Microsoft Graph with the User.Read
scope already consented at sign-in; it never reaches the backend. Any failure
(no photo, token, network) falls back to the initials. One request per account
and tab, cached in sessionStorage including the no-photo answer.
The four copied avatar blocks in the navbar become one UserAvatar component.
Graph host added to the URL registry and to connect-src in both CSPs; the
privacy page states where the photo comes from and that it is not stored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A process name is not a protected namespace: any user session can keep a
binary named like the agent resident, which made every agent start during
that session exit 1 before registration and let install mode report a
runtime launch that never happened.
The guard and install mode's launch verification now share one rule
(FindSiblingAgentPid): a name match counts only in session 0 and never for
the caller itself. Every runtime start lands there (Scheduled Task, WMI
launch from the SYSTEM install chain, self-update restart), so behaviour
for real agents is unchanged. The session id comes from the process
snapshot, so no handle is opened; a process that cannot be read does not
count, which fails open into two agents rather than none.
Two interactive --console runs in the same user session no longer block
each other; an interactive run next to the session-0 runtime still does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Banner and the success-rate/duration gauges read the evaluation period
(last 30 days) instead of the current ISO week and name the window in their
labels; the app install gauge says "this week". A target whose period holds
no finished enrollment renders a neutral "No data" card and is left out of
the banner count; with nothing to judge the banner is neutral.
Banner logic moved to lib/slaStatus.ts (pure, tested).
Needs the backend of the previous commit: the page reads
evaluationPeriod / evaluationWindowDays.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SLA breach alert judged the calendar month while the dashboard headline
showed the current ISO week, so an alert could report 78 % while the page it
links to showed 0 everywhere - and an empty week rendered as 0.0 % "Breached".
- SlaEvaluationWindow is the single definition of the SLA populations:
success rate and P95 duration on terminal sessions of the last 30 days
(rolling), app installs on the install attempts of the current ISO week.
The breach evaluator and SlaMetricsService both read it, including the
one-decimal rounding, so the value shown is the value judged.
- SlaMetricsResponse carries EvaluationPeriod and EvaluationWindowDays;
SlaSnapshot carries Period and HasData. A period without a finished
enrollment meets every target flag and is marked HasData = false.
- The dashboard reads from whichever starts earlier, the selected months or
the evaluation window; trend and violators stay on the selected months.
- A lasting breach is notified again only after the cooldown AND once
something finished since the last notification. A new breach episode after
a resolve still notifies. Resolve notifications are unchanged.
- Alert period fact and telemetry tag follow the window constant.
- SlaMetricsService takes a nowProvider; the week-boundary workaround in its
tests is gone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configured diagnostics entry is resolved once to folder, pattern and recursion, and
that target is what gets validated and enumerated; recursion asks the guard before
entering a subfolder, and the user-profile token is expanded before validation.
Security, PowerShell and Sysmon event logs are refused per file, by name and by
resolved channel, in every mode; the portal flags such a path before it is saved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Directly under Dashboard the Progress Portal collected misclicks: 418 of 1,050
admin page views in 90 days went on to the dashboard within 5 seconds, from
every origin page. It is the end-user page; regular users keep it as their only
entry via REGULAR_USER_ITEMS. Annotations showed the same walk-through pattern
and moves down with it. Fleet Health, Geographic Perf. and Software lead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
All five cards showed the same bar-chart glyph on a differently coloured
block, so the colour carried no meaning. Each card now has a small grey
outline icon for its metric (pulse, check, stopwatch, calendar, x) in the
top-right corner, and the label gets the freed width. Failed Today turns
its value and icon red only when the count is above zero and reads
"No failures" at zero.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The analyze and gather rule pages loaded the community submissions list for
every role. rules/submissions is TenantAdminOrGA, so an Operator or Viewer got
a 403 and a red "Failed to load your community submissions" banner on every
page visit (16 denials in 24 h from two operators of one tenant).
The pages now skip the list for callers who cannot read it and the
contribution box says that only tenant admins can contribute and follow the
tenant's submissions. Tenant admins, Global Admins and a global-scope override
are unchanged; the backend policy stays as it is.
Bumps the internal and .claude/commands gitlinks (platform-health marker
catalog, backlog).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A retry after a poisoned offboarding is a new envelope with a new history
row, and nothing ever deleted from a poison queue. The dead letter of the
failed run therefore stayed until its 7-day TTL and kept the poison backlog
alert firing daily for a tenant that was already gone.
TenantOffboardingHandler.HandleAsync now reports a TenantOffboardingOutcome.
On Completed the worker receives tenant-offboarding-poison in batches and
deletes the envelopes of that tenant; other tenants' and malformed messages
reappear untouched. The sweep never throws: a failure there would keep the
completed envelope on the main queue and end with a Completed run in poison.
A re-pickup of a completed envelope sweeps again.
Other poison queues stay terminal on purpose; their failures have no later
success that supersedes the dead letter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The trigger ran the whole maintenance inside the HTTP request (about three
minutes against a 230 s gateway limit): the portal reported a timeout while the
run went on, a second click started a second run, and nothing serialized those
runs against each other or against the 2h timer.
Backend
- MaintenanceRunGate: the timer and the manual run enter through one blob
lease. A held lease writes MaintenanceSkippedLocked and runs nothing;
MaintenanceStarted is written after the lease is held, the lease is released
last.
- POST maintenance/trigger validates the date, probes the lease (409 while a
run is active), enqueues on maintenance-trigger (fail-hard) and answers 202
with the typed TriggerMaintenanceResponse. MaintenanceTriggerQueueWorker
(self-polling) runs it; a failing or skipped run is not retried.
- The run report travels in the details of MaintenanceCompleted.
- BlobLeaseLockStore is the shared base of the lease lock stores;
SessionDeletionMaintenanceLockStore derives from it, behaviour unchanged.
- The probe lets a failed lease release surface as 5xx: a probe lease still
held at worker pickup would turn the queued run into a silent skip.
Web
- useOpsRunStatus: run status from ops events (latest Started newer than the
latest Completed/Failed = active), used by the session-cleanup banner
(rendering unchanged) and the maintenance trigger section.
- Trigger section: queued / already-active messages, status line, polls every
15 s while a run is active, button disabled meanwhile. The 230 s fetch
timeout is gone.
Shared manifest, generated wire types and the MCP ops-event vocabulary
regenerated (two new ops event types, reshaped response).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A Failed offboarding was a dead end: the worker returns early on a Failed
history, the offboard re-click refuses to re-enqueue on a Failed marker, and
the marker cleanup only warns. Recovery meant deleting the marker row in Table
Storage by hand, while the tenant sat in the list as a tombstone counted on the
Waitlist (its whitelist row is wiped early in the cascade).
- GET global/tenants/{tenantId}/offboarding returns the offboarding record
(marker status, failed phase, attempts, history details) as the typed
TenantOffboardingStatusResponse; GA + Reader, never delegated.
- POST global/tenants/{tenantId}/offboarding/retry re-drives a Failed run: the
History row keeps its RowKey and DrainCompletedAt (the worker's resume anchor)
and counts the retry, marker and history go back to Initiated, the ordinary
resume path re-affirms the Disabled-gate, waits out the remaining drain
barrier and enqueues the envelope. Completed / in-flight answer 409, no
marker 404, a missing history row 500 before any write. Global audit row
RETRY/TenantOffboarding. Nothing retries on its own.
- Tenant editor: an "Offboarding" card replaces the Offboard action while the
cascade owns the row (status, started by, attempts, failed phase and error);
Failed offers "Retry offboarding…" behind the type-to-confirm dialog in its
retry variant.
- Tenant list: tombstone rows carry an Offboarding badge (own Status facet)
instead of Suspended, and no longer count as Waitlist.
- Tests: retry decision (reset order, drain anchor, conflicts, not found,
enqueue failure, remaining-barrier delay), status projection, wire parity
facts, filter facet. Shared manifest and generated wire types regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPG5WCmSwfNAv4xkK62n5r
The run answers when it is done (about three minutes); the default fetch
timeout reported "signal timed out" although the run completed, which invites
a second click and a second run. Wait up to the gateway limit of 230 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The landing-page counters stood still since D-198: the two-hourly recompute
was their only writer and merged Max(live figure, stored figure), while
retention prunes the live figure below the stored one - so new enrollments
never showed up again.
- Hot path increments the per-tenant counters only (CAS): enrollments at the
fresh registration, successes at the one status seam every Succeeded writer
passes (transitioned && Succeeded), events per ingest batch. No batch
touches the global row.
- The maintenance run floors the tenant counters at the live figures (one
multi-field write per tenant), sums the tenant rows and adds the growth
since the previous run to the platform row. The previous sums live in a
baseline row written in one transaction with the platform row. A total is
never derived from an absolute figure again: a shrinking source (retention,
offboarding) costs the growth of one interval instead of freezing the
counter. Without a baseline the sum is a lower bound only.
- TotalTenants rolls up the tenant rows with enrollments, UniqueDeviceModels
a persisted set of seen models. TotalUsers stays a high-water-mark (a
persisted user set would outlive the user-activity retention).
- The platform row is merged with its read ETag and never carries
IssuesDetected: the former full-row upsert undid every increment that
landed during the run.
- Reads that feed the baseline are not fail-soft, and a floor that could not
be written skips the rollup of that run - a swallowed zero would be added
as growth a second time.
Shared manifest and generated wire types: doc comment of PlatformStats only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wipe of the tenant's own self-service Tenant Group handed the derived
group id ("msp-{tenantId}") to the tenant-GUID entry points of SafeWipeService.
Their GUID guard threw on every attempt, so each offboarding since the
self-service delegation change ran into max_dequeue and ended Failed after the
tenant tables were already wiped.
- SafeWipeService: WipeByOwnedGroupPartitionAsync / WipeByOwnedGroupRowKeyAsync
take the tenant GUID and derive the group id themselves; matching filters in
OffboardingFilters.
- The public wipe entry points are no longer virtual: they validate and hand
over to protected virtual cores. Test fakes override the cores only, so the
guard runs under every fake. With the old handler code 16 handler tests fail
on the guard, which is how this shipped unnoticed.
- Tests: handler regression on the owned-group anchors, owned-group variants
incl. verify-abort on a foreign group, filter shapes and guards.
Deploy and recovery of the stuck offboarding: B-5g6.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replaces the two Ready/Waitlist toggles with a Filter popover of five
facets (Plan, Paying, Trial, App registration, Status): OR within a
facet, AND across facets, each option carrying the count it would match
under the other facets, active selections as removable chips, and a
plan breakdown (Community / Pro with paying / Trial / MSP) over all
tenants in the statistics line.
The list badge, the filter and the edit modal now derive the effective
edition through one function; a trial-only tenant reads "Pro (Trial)"
in the list instead of "Community". The App facet is bound to the
dual-app parallel window (legacyConfigured), like the badge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The DPA is published in the customer docs and accepted on the Get started
page before the first sign-in (D-251); the onboarding records the accepted
version on the tenant (D-252). The legal pages still described it as
available on request and as a Pro-only signed document.
- privacy: the Roles section says the DPA is published, applies to both
plans, is accepted at sign-up with the version recorded, and that an
individually signed DPA prevails; External Services links the DPA and its
sub-processor list; Changes no longer hinges on "if you hold a signed
DPA"; Contact no longer offers to send one.
- terms: the DPA is no longer listed as a Pro differentiator; Security and
Transparency links the DPA as applying to both plans. Last updated
11 September 2026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backend/Shared:
- McpQuotaService: ResolveHomeAsync reads definitions, entitlements, plan name and purchased slots once per check; Grow adds slots × per-slot value to every window (a lifted window stays unlimited); the decision and McpPlanLimits carry the breakdown; CheckManyAsync, the charge scope and TargetTenantId are gone
- McpQuotaEnforcementMiddleware: one home charge for every caller; IsExempt = Global Admin only (counted, never blocked; the Global Reader stays quota'd); two 429 texts (organization / user)
- TenantEntitlementService.GetPurchasedDelegatedSlots = max(0, effective slot limit − catalog count), 0 without the delegation right; FeatureEntitlementCatalog gains McpSlot*RequestLimit (Pro 300/6000 per account, 900/18000 per organization = 30 % of the Pro windows; Community and conferred Pro 0)
- PlanTierDefinition gains four nullable slot fields; GET/PUT global/config/plan-tiers return the catalog (UsagePlanCatalogDefaults per edition) so a blank field is explained
- McpUsageQuotaNode / McpOrganizationQuotaNode carry purchasedDelegatedSlots and the slot values (absent without purchased slots)
- Removed: the Tenant Group flag ChargeHomeTenantQuota with its scope plumbing (HomeChargedTenantIds, IsDelegatedAggregate, CatalogDecisionResult), Narrow/quotaExcludedTenants on the session envelopes and the X-MCP-Quota-Excluded-Tenants header, targetTenantId on the 429 body, the foreign-home attribution on McpTenantUsage (HomeTenantId, delegated/homeTenantId on the organization report) and the managed tenant's budget on the manager's managed-tenant list; PATCH global/tenant-groups/{id} is a rename
Web: SectionUsagePlans with four slot inputs, catalog placeholders and a built-in-defaults line; SectionMcpUsage renders "base + slots × per-slot" on both cards and drops the delegated badge; SectionTenantGroups without the charge toggle; SectionDelegatedAccess, the accept page and the privacy page state the new model (privacy last updated 2026-09-11).
MCP: get_fleet_overview no longer merges an exclusion list, the 429 renderer names only the caller's organization, the delegated-scope instruction states the home-charge model; wire types regenerated.
Tests: quota service slot-growth suite, entitlement purchased-slot matrix, conferred-Pro self-service writes (403/409), parity facts for the new and the removed fields; the radar analytics fixture is dated off the real clock (it had aged out of the 30-day window).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The self-seed of every fresh backend instance treated the embedded catalog as authoritative and wrote it over a newer GitHub reseed: on 2026-09-11 it re-inserted PS-SCRIPT-EXITCODE three hours after the reseed had deleted it (D-248), so agents ran 83 patterns. Every explicit reseed now stamps its kind in AdminConfiguration (PK RuleCatalog); the three self-seeds do nothing while a github stamp is newer than BackendBuildInfo.BuildUtc, and an empty table is still seeded from the binary. The IME self-seed deletes built-in rows the embedded catalog no longer ships.
ImePatternHealthService (health matrix and the ime_pattern_hits ingest filter) takes the pattern list from the seeded global partition via ImeLogPatternService.GetGlobalCatalogAsync, never from BuiltInImeLogPatterns, so the health page and the drift statistics follow a reseed without a deploy; the response carries catalog { source, stampedAt, patternCount } (manifests regenerated, web line, MCP description).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test-VM session c3ecb568 (2.0.1460): the tracker loop was not scheduled for 26 s at 100 %
VM CPU while the rest of the agent kept running; five results were read before their
executor end blocks — held and completed correctly (D-247) — and nothing in the fleet
telemetry could have shown the pause (ime_backlog_bytes is an instantaneous value).
- ImeLogTracker.RunPollPassAsync hosts the loop body (file reads, the pending-completion
flush, the token-failure window) and times it on a monotonic clock (MonotonicMillisProvider,
Stopwatch in production): PassMaxMs = longest single pass (time INSIDE — reads, ledger
verification, regex), PassGapMaxMs = longest pause between the end of one pass and the
start of the next (the 100 ms delay, SaveState and any scheduling delay). Restart-safe
like the other health counters (TrackerHealthData); ImeTrackerHealth carries both.
- agent_metrics_snapshot: ime_pass_max_ms / ime_pass_gap_max_ms; ime_pattern_hits:
passMaxMs / passGapMaxMs.
- MCP session coverage: sessionTotals.passMaxMs / passGapMaxMs, and from 5 s on a note that
says the lines were read late, not lost — every event keeps its log line's timestamp; the
overwrite note and the pause note join when both apply.
- Tests: ImeLogTrackerPollTimingTests (maxima over three passes with a fake clock, fresh
tracker at zero, restart keeps and never lowers them); MCP coverage tests for the note
threshold. Agent suite 2941 green, solution build 0 warnings, MCP tsc clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test-VM session 24dc69d1 (2.0.1459) had the overwrite geometry the result hold (D-247)
was built for, yet the hold never fired: "Powershell execution is done, exitCode = 0" —
written by IME's health-script worker (AgentCommon ScriptWorker) to both
IntuneManagementExtension.log and HealthScripts.log — was read as PS-SCRIPT-EXITCODE and
attributed to the pending platform script c81b8053 1.2 s before its executor even started
(IME-log marker: the last PS-SCRIPT-GENERATED; HealthScripts.log: the no-marker fallback to
the last platform start). The completion went out at the result line with the foreign exit
code, and the run's own end block, recovered 150 ms later by the rewind, was dropped as
already emitted. Both codes were 0 by luck; a failing health script would have turned a
succeeded platform script into script_failed. The worker's launch line flipped the pending
script's context the same way (338d9434, twice in one run).
- rules: PS-SCRIPT-EXITCODE deleted — the line is health-script-only (3 hits = 3 cycles,
platform scripts never produce it), HS-EXITCODE already carries the value, and a line that
lands inside a parallel platform run's window cannot be attributed by position.
HS-INVOCATION (new): the worker's command line `"…\agentexecutor.exe" -remediationScript`
(identical from IME 1.104.102 to 1.105.152) as agentInvocationArgument of a non-powershell
kind — the launch line it writes next stays with that invocation. Both take effect on the
next reseed for every agent from 2.0.1458 on.
- tracker: a file without markers resolves to the platform run in flight only when a
platform run has ever opened in that file (IntuneManagementExtension.log after a rollover,
AgentExecutor.log); HealthScripts.log and the app logs never qualify. Test seams:
ProcessLogMessageForTest takes the source file, ClearInvocationMarkersForTest.
- tests: ImeLogTrackerPlatformScriptContaminationTests +5 with the session's real lines —
the worker's exit line pre-fills nothing, its launch line flips no context, HealthScripts.log
lines resolve to no platform script, the context line after an IME-log rollover still finds
the run in flight, and no shipped pattern reads the worker's exit line.
- rules/dist regenerated (node rules/scripts/combine.js); analyze-rules.json catches up with
ANALYZE-OFFICE-001 1.1.1 (6657c6b9 was committed without its dist).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
office_install_failed is emitted only for an error code in the C2R INSTALL scenario; a window without on-disk proof ends silently. The old text described a StreamingFinished-based failure and attributed the early "done" of the built-in app type to IME, which never sees Office CSP installs. Text only, version 1.1.1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first agent with the ledger (2.0.1458, session e7f3c910) lost nothing — 16/16 results,
20/20 end blocks, 20 rewinds — yet 3 of 16 completions carried no exit code and one policy
got an agentexecutor_fallback duplicate. The IME result line reaches the tracker before the
AgentExecutor.log end block of the same run: always when the block was overwritten (the check
the result triggers recovers it one pass later), often without any overwrite (the
AgentExecutor.log read of a pass precedes the IME log read by tens of milliseconds while the
exit line and the result line are 8–30 ms apart), and whenever the poll loop stalls (2 s at
100 % VM CPU). Emitting at the result line dropped every recovered end block, and the late
start line reopened the slot, cleared the emitted-marker and the fallback emitted the same
run again 15 s later.
- CompletePlatformScriptFromImeResult (one core for the handler and the test seam) emits at
the result line only when the slot has its exit code; otherwise the result stays on the
slot (ResultObservedAtUtc, ResultPatternId, ResultHeldSinceUtc) and
FlushPendingPlatformScriptResults — the former FlushStalePlatformScriptResults, still the
home of the 15 s exit-code fallback — emits it at the end of the pass in which the exit
code arrived (the block's output lines come with it), at shutdown, or after
PlatformScriptEndBlockGrace (5 s, wall clock).
- The emitted-marker is a Dictionary keyed by policy id with the run's source timestamp
(state file: PlatformScriptResultEmittedAt; the old list is read as MinValue). A start
line strictly older than the emitted result is the late start line of that run: it owns
the lines behind it in its file, opens no slot, emits no script_started. A start line at
or after a held result closes the hold as the next run; a start line that meets a slot
the result opened stamps StartedAtUtc.
- The adapter binds the completion to the result line (ResultObservedAtUtc, patternId =
ResultPatternId) the way the fallback binds to the exit line, so a held emit keeps its
timestamp, pattern and duration.
- Exit-code lines whose owner has no pending slot any more log why they are dropped;
ApplyExitCode is shared by the handler and the test seam.
- Tests: ImeLogTrackerResultHoldTests (11) and ImeLogTrackerAdapterResultHoldTests (1); six
existing cases that encoded the immediate emit without an exit code now flush explicitly.
Agent suite 2933 green, solution build 0 warnings.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent (v2.0.1458) counts the log blocks it re-read after a concurrent IME process had overwritten bytes behind its bookmark. The IME Tracker rows on the session page show the count and bytes as their own row, apart from Skipped work; the MCP coverage block adds both counters to sessionTotals and, when rewinds > 0, an imeTracker.note saying the script results and exit codes in those blocks are complete — no coverage.gaps line, no degraded flag, so an assistant does not read recovered data as a blind spot. Sessions from older agents carry neither key and show neither.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IME's trace listener opens every log once per process with FileMode.Append and FileShare.ReadWrite
and writes at its own stream position afterwards. AgentExecutor.exe (two processes per user-context
script, one per detection script) has such a listener on AgentExecutor.log and on
IntuneManagementExtension.log, so a process that resumes logging after another one appended
overwrites those bytes: the final file holds the last writer, a byte-position tailer has read the
first version. Session 46749560 lost 6 of 16 platform-script results and 4 of 16 executor end
blocks with every tracker health counter at zero — results fell back to the exit code (one Success
where IME said Failed), one finished script stayed Incomplete. The agentexecutor_fallback results
in the fleet are this race, not late IME logging.
The tracker now keeps a fingerprint ledger of every entry it processed from the two multi-writer
files (FNV-1a 64 over the decoded bytes, one CmTraceEntryAssembler shared by the read loop and the
verification so entry boundaries are identical by construction), verifies it on every script end
signal, on a byte-budgeted cadence while an invocation is in flight (256 KB/s, at least 1 s
apart), in a 4 KB guard zone on every growth pass and on a fragment at the bookmark, and rewinds
to the first changed entry; entries whose (offset, hash) still read unchanged are skipped,
everything else is processed as backlog (never fresh, never a calibration anchor). The
policyId-less executor lines are owned by the nearest preceding invocation marker in their file
(PS-AGENT-ARG opens, PS-AGENT-SCRIPT-START upgrades to the platform script, PS-AGENT-INVOCATION and
PS-AGENT-COMPLETED close) instead of a last-started pointer, so an end block a rewind uncovers
reaches its own script. Restart seeds the ledger from the 64 KB behind the persisted bookmark;
a rollover clears it. Four restart-safe counters (ime_overwrite_rewinds,
ime_overwrite_bytes_reprocessed, ime_verify_passes, ime_verified_bytes) ride agent_metrics_snapshot
and ime_pattern_hits — recovered data, not skipped work, no new event type.
Tests: ImeLogTrackerOverwriteRewindTests (15) drive the positional-overwrite write pattern against
both files, including the two field scenarios with the shipped patterns; agent suite 2921 green,
backend known-actions list extended for the two new patterns.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SecurityValidator stamps ValidatedTenantId once the certificate tenant binding
(or the bootstrap token) proves the tenant, and DeviceValidation once the
device-validation chain has run: the admitting ValidatorType name, None for a
tenant without a validator, Transient for the 503, Rejected for the 403 of
stage device. RequestTelemetryMiddleware resolves the row's TenantId in one
place (policy tenant, proven tenant, then the X-Tenant-Id header only on the
routes that validate it) and copies DeviceValidation as a dimension.
Register, config and upload-url rows carried no TenantId before, so
tenant-filtered KQL missed exactly the calls that run the validation, and the
validators' success line is Information-level and never reaches App Insights.
Proving which validator admitted a device is now one query.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Session 2ebcd480: the last platform script started 27 s after enrollment_complete and its
start line was the session's last event; two days later the Script Executions panel still
ticked "Running (175306s — stuck?)" in amber with "1 running" in the header. Same blind
spot as D-237, one panel over.
Web: applyObservationEnd in lib/scriptExecutions.ts marks a Running placeholder on a
terminal session as Incomplete (bounded by session.lastEventAt); the row stops ticking and
shows "Incomplete" in slate with the watched span as a lower bound, the header counts
"N incomplete" apart from running/succeeded/failed, and the card keeps its React key across
the live→terminal flip. No backend change: there is no per-script row, and the session
counters count finals only.
MCP: get_session_summary adds coverage.scripts.stillRunning and a coverage.gaps line for
terminal sessions, folded from script_started without a later result — the same rule as the
web placeholder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sharp < 0.35.4 bundles a libheif with GHSA-g89c-p67h-r497 and
GHSA-2jg2-4ch7-h545 (Dependabot alert #100, high). Both packages already
pinned sharp through an override; the floor moves to the patched release.
MCP pulls it via @huggingface/transformers, Web via next. Lockfile changes
are limited to sharp, its @img/* binaries and one @emnapi/runtime dedupe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- GetMcpOrganizationUsageResponse.daily: charged requests per day inside the range from the same McpTenantUsage rows (BuildOrganizationDaily, MSP reads included); manifest + wire types regenerated (web + MCP copy); fold test
- Web: viewing another tenant no longer fetches the caller's own usage — no UPN, plan badge or own quota; the summary tiles and the daily chart come from the tenant's daily series. The own tenant stays the caller's usage page. Internal gitlink.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reporting layout (ReportingSidebar) now owns the override-only scope, renders the banner above the header and the selector next to the title like Fleet Health, and hands the scope to the sections through ReportingScopeContext (one hook instance). Delegated callers get neither. Fixes the five-line wrap of the section control row on phones; internal gitlink.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Backend: GET global/metrics/mcp-usage/organization?tenantId= (GlobalReadOrAdmin, TenantScoping.None on purpose — the route enumerates accounts, so the delegated path stays out; 400 without a GUID, no aggregate); GetMcpOrganizationUsageResponse carries `quota` (tenant plan + daily/monthly windows summed from the same McpTenantUsage rows); McpQuotaService.ResolveTenantPlanAsync with public TenantPlanLimits; two folds tests
- Web: TenantScopeSelector on /settings/reporting/mcp-usage (override-only, hidden for a delegated scope), scopedApi.mcpOrganizationUsage, Organization-quota card from the organization response (falls back to the caller's own node only on the own tenant), latest-wins fetch guard, wording follows the viewpoint
- Manifest + wire types regenerated (web + MCP copy); internal + skills gitlinks
Deploy order: Backend before Web — under a tenant override the page calls the new route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment-only. The 500 KB figure is query_table's cap; query_raw_events serializes under
MAX_RESULT_SIZE_CHARS.events = 250 KB. Live 2026-09-10: 100 app_install_failed rows = 193 KB.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The band and its tiles cover the observed span (until the agent stopped); the
session page measures the enrollment (first activity event → verdict). The
tiles now carry that figure as a sub-line so both pages read the same two
numbers. enrollmentWindowOf in lib/standby.ts is the shared definition, used
by the session hook as well.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Revises 14b185db after the pre-release review. The fleet measurement (30 days,
all tenants) showed 87 of 821 sessions with a sleep episode had their verdict
inside the episode — 59 of them one tenant's self-deploying devices closed two
minutes before the device-only detection came due and reopened 5–7 days later.
Stamping the firing clock would have turned those outages into session
duration and pushed the tenant's mean duration from minutes to days.
- OnDeadlineFired posts OccurredAtUtc = DueAtUtc again; the due time stays in the
payload and the two stale-fire guards keep reading it (any stamp stays safe).
- Network Timeline: a timeout verdict (helloOutcome timeout, device-only detection
window) is drawn as a hollow marker "Completed (timeout)" with a legend line and a
plain-language tooltip — a wait expired, nothing observed on the device; the
Asleep block is never split at it. finalizing_grace alone is not an indicator.
- Session Info standby note clips to the two WhiteGlove spans when the card shows
the combined WhiteGlove duration; the dedup key is consumed only by a usable
observation (review findings).
- Stale comments describing the firing-clock contract corrected.
Tests: DeadlineFireTimeStampTests (reducer stays correct for any stamp),
EnrollmentOrchestratorTests (due-time stamp + payload), networkTimelineModel
timeout markers (3), standby windows, WhiteGlove windows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Request gates: an Origin other than the server's own is refused with 403 before any token
work (spec MUST); a token the backend rejects is answered 401 + WWW-Authenticate
error="invalid_token" instead of a final 403. The auth.ts header now names the
backend-audience token forwarding as a deliberate deviation (D-239) instead of calling
it spec-conformant.
Secrets: the per-argument echo policy travels in the tool-call context, so toolError's
"Parameters used" block masks update_tenant_config.fields the same way the log line
does. revert_tenant_config requires backupId (a retried revert without one restored the
snapshot the previous revert had just created). /oauth/token relays Entra errors through
an allowlist (error, capped description, error_codes). OAuthStateSigningKey is required
in production (B-uba).
Results: toolResultText enforces the response cap — a page above it is refused with an
overflow answer naming the pageSize that fits (from this page's bytes per row), never
rows or a nextLink; the telemetry logs the size the page would have had (B-zhp).
query_raw_events with DataJson in the effective projection defaults to 50 rows and
refuses more than 100 before the backend runs (30-day measurement: p90 3.3 KB, outliers
21-29 KB per row) (B-s2y). The caller context carries the request's cancellation signal:
apiFetch, the retry wait and the timeout fallback stop when the client goes away, and the
tool_call line logs cancelled instead of an error.
Tests: 9 new files (origin guard both eras over HTTP, 401 + cancellation, apiFetch abort,
arg policy in error text, signing key, token error body, overflow, raw-events pageSize,
telemetry overflow/cancelled); vitest 844 green, tsc 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A HelloSafety deadline due during Modern Standby fired after wake but was stamped
with its due time, so enrollment_complete and CompletedAt landed inside the sleep
window: the Network Timeline drew "Completed" in the middle of the Asleep block
and three views named three different standby durations.
- OnDeadlineFired posts OccurredAtUtc = FiredAtUtc and carries the due time under
SignalPayloadKeys.DeadlineDueAtUtc; the stale-fire guards (RealmJoin re-arm,
SelfDeploying guard C) identify the incarnation via DecisionEngine.DeadlineDueAtUtc,
which falls back to the signal time for pre-key signal logs.
- SignalSerializer renders Date-tokenized payload strings as ISO-8601 round-trip text
instead of the culture-formatted JValue.ToString() (a replayed deadlineDueAtUtc came
back as "4/20/2026 9:55:00 AM").
- Web: the Session Info standby note is clipped to the same window as the duration
next to it (lib/standby.ts) and printed with seconds, matching the attribution chip.
Tests: DeadlineFireTimeStampTests (late HelloSafety fire, guard C, RealmJoin re-arm,
helper fallback), SerializationTests date-like payload round-trip,
EnrollmentOrchestratorTests late fire, lib/__tests__/standby.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An app still installing when the session's observation ended has no terminal
event and never will. The session page ticked Date.now() - startedAt for it
("11h 39m" on a session that finished hours ago), and its AppInstallSummaries
row stayed InProgress forever - ranked behind failures in the app's session
list and counted in totalInstalls with no named bucket.
Web: applyObservationEnd marks such rows Incomplete once the session is
terminal (isTerminalStatus), bounded by session.lastEventAt; the panel shows a
slate Incomplete badge and the watched span as a lower bound, the "active"
pill excludes them, and the download panel carries the parallel flag. Covers
every historic session without a backfill.
Backend: CloseOpenAppInstallsForSessionAsync closes InProgress rows without a
CompletedAt as Incomplete - at the terminal seam, again on the ingest batch
that carries agent_shutting_down for a terminal session (rows opened after
the verdict), and from the 30-day sweep. The sticky-status reconcile keeps
Incomplete against a late progress-only batch and lets a real terminal win.
App metrics and app analytics disclose the bucket (incomplete,
totalIncomplete) outside the failure rate; manifest and wire types
regenerated.
MCP: get_session_summary coverage gains an apps block and a gaps line naming
the apps the agent's last app_tracking_summary still listed as installing or
downloading, terminal sessions only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closed submissions leave through the daily maintenance sweep (RuleSubmissionRetention, one place for rule and test); pending/approved/published never. The submit dialog takes an optional contact email (a UPN is not always a mailbox), stored for the reviewer and never published. The tenant list shows pending rows by default with the rest behind Show all, and the contribute button shows for every writer, also before the first custom rule exists. DELETE global/rule-submissions/{id} (GlobalAdminOnly, MCP delete_rule_submission) removes test/demo rows for operator and tenant alike. Bumps internal and .claude/commands gitlinks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tenant admins submit references to their own gather/analyze rules; the server freezes the rule, records pre-flight findings and fire stats, and stores it under a 12-character id (RuleSubmissions, SessionReports layout). Attribution is the submitter's frozen choice (anonymous by default) and becomes the published author. Review (approve with reserved id / decline with comment) through /admin/reports/rule-submissions or the MCP tools list_rule_submissions, get_rule_submission, review_rule_submission, reseed_rules_from_github; published is derived from the global catalog, never stored. Analyze rules now get the same server-side author stamp as gather rules; ANALYZE-ID-001 carries author Community. Privacy page names the credit; bumps internal and .claude/commands gitlinks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ANALYZE-ESP-004 fired on every ESP terminal failure of a Continue-anyway
profile and rendered the configured FirstSync limit as elapsed time — the
mayHaveContinuedAnyway flag only says the profile has the button. The rule
now requires the agent's own verdict (app_install_failed with
failureType=esp_apps_timeout), names that app via a same-event filter, reads
failedSubcategory from esp_failure_settle_started and words the limit as a
limit. Verified with test_analyze_rule: cb7036a6 fires, 683f1eff (0x80070652
after 19 min) stays silent.
Rule engine: not_exists with a dataField on event_type/event_data
conditions matches when no event of the type carries a non-empty value —
including an absent type — instead of being dead behind the fieldValue null
short-circuit. Schema and MCP authoring guide say so.
Agent: a Shell-Core 62407 failure that lands inside the provisioning
tracker's 30-s settle window (a third of all ESP terminal failures) now
inherits the pending errorCode / failedSubcategory / category /
likelyCulpritApps, so enrollment_failed, Sessions.FailureReason and the
termination-time app classification see the HRESULT the registry had
already named. failureType stays the Shell-Core value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Entra family (D-231) pushed utils/error-codes.json to 123026 bytes against
the 120000-byte client-bundle budget, breaking the Web CI job.
Raising the cap would have been the wrong repair -- it was set three days ago
with the v2 catalog, and a guard that gets widened whenever content grows is not
a guard. The bytes were elsewhere: confidence "high" holds for 562 of 641 rows
and source "msdoc" for about half, ~19 KB of bundle spent repeating two
constants. The sync now omits them and errorCodeMap restores them while building
the map, so every consumer still sees a complete entry and errorCodeTooltip
renders exactly as before. 123026 -> 102399 bytes, cap unchanged, ~17 KB of
headroom left.
Dropping the fields outright was rejected: the tooltip renders both, so they are
provenance a customer sees. The vocabulary test now asserts on the resolved
entry via getErrorCodeEntry rather than the raw file -- a default changed on one
side only would otherwise make tooltips lie about provenance silently.
internal: D-231/D-232, the plan with its review, backlog B-y5d and B-s2y.
.claude/commands: the platform-health marker-catalog corrections from the
2026-09-09 health pass (weekday vs weekend ingest baseline, CAS retried scales
with load, WhatsNewNotificationSent is not proof of a run).
Seven of 44 tool errors in the 7-day telemetry were invented catalog values, and
in every case the tool description already said the right thing while the
parameter description was bare or contradicted it.
update_tenant_config is the clean proof: its description says "Call
get_tenant_config_schema FIRST", but the fields parameter said "Use
get_tenant_config for current values and exact field names" -- and over 30 days
the model called get_tenant_config 12 times and get_tenant_config_schema once.
The text next to the argument wins, and get_tenant_config cannot answer what it
was pointed at: it shows only the fields this tenant has set, with no type,
writability or nullability.
Also: get_session_events.eventType (event_types catalog, matching the pointer
search_sessions_by_event.eventType already carries), query_table (list_tables)
and test_analyze_rule.rule (rule_schemas, validate_rule first).
The catalog held 2 of 607 entries in entra-join, while the 0x801Cxxxx DSREG
family is what an enrollment meets during hybrid join and the ESP account-setup
phase. The Windows Hello PIN error page is the only MS table that documents it;
both of its tables are now parsed, restricted to 0x801c* (607 -> 641 entries).
The block runs after PROSE_ENTRIES so the Autopilot-specific text for 0x801c03ea
keeps precedence over the generic one. The Mitigation column is not carried --
the catalog describes codes, it does not advise. A "SYMBOL: text" cell yields a
symbol (0x801c004d -> DSREG_NO_DEFAULT_ACCOUNT). The run fails below 30 keys,
because parseTables drops non-matching rows silently and a reshaped page would
otherwise look like a clean run.
decode() now strips zero-width and bidi marks: the page carries U+200B in one
hex cell and four description cells, JS \s does not match it, and such a hex
cell fails every ^0x...$ test. Placed in decode() rather than in the new source
so the next parsed page cannot walk into it -- regeneration shows 34 added,
0 changed.
0x801C044F, the zero-hit lookup that started this, is on none of the three MS
pages and deliberately stays unknown.
The What's new panel prints "Latest agent version" under the release
entries, and the session list draws its outdated badges from the same
endpoint — but the value was cached 12h in each Function instance and 1h
in the browser, so the panel announced the previous release for up to 13h
and two instances could disagree.
- LatestVersionsService: TTL 12h -> 5 min; keep the last successful result
and serve it when the blob read fails, so the shorter TTL cannot blank
the version line on a transient hiccup (failure marker 5 min -> 1 min).
- GetLatestVersions: Cache-Control max-age 3600 -> 60.
- No refresh callback from build-agent.yml: the cache is per instance, a
warm-up request would leave every other instance stale.
The origin is a ~200-byte no-cache blob behind the download alias that
Front Door does not cache, so a warm instance now reads it at most 12x/h.
Backend deploy (workflow_dispatch) pending — B-uq7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gather rules and admin-configured diagnostics paths could never reach a
folder below a user profile. The %LOGGED_ON_USER_PROFILE% token lifts the
C:\Users hard block for AppData\Local and AppData\Roaming, but lifting is
not admitting: the path then fell into filePrefixes, which is matched
literally against the already-expanded path, and the profile name is not
known when that list is authored. The exception only ever worked in
unrestricted mode, and no agent test passed a non-null userProfilePath.
D-228 introduces userProfileFilePrefixes, holding entries relative to the
profile root (AppData\Local\RealmJoin). Both guards resolve them against
the detected profile and match segment-bounded; entries that are rooted,
contain "..", or sit outside AppData\Local / AppData\Roaming are dropped at
load, so configuration can only narrow the hard block's exception, never
widen it. The allowlist is anchored to the detected profile, so a rule
cannot reach another user's AppData. The AppData subdirectory list existed
in both guard classes with the GatherRuleGuards copy unused; it is now one
list and DiagnosticsPathGuards delegates. filePrefixes additionally gains
C:\ProgramData\RealmJoin\choco\logs.
D-229 fixes the pre-flight validators, which must only ever reject what the
agent rejects:
- the portal returned "allowed" on the lifted hard block without consulting
the allowlist, so a rule the agent refuses passed the enable gate and then
failed silently on the device with a security_warning;
- the MCP validator offered logparser the diagnosticsPathPrefixes list that
no collector consults (four paths reported allowed that the agent blocks);
- the MCP validator did not expand environment variables at all, reporting
%ProgramData% targets as blocked although the device reads them.
Measured before changing the portal: no rule in any tenant used the token,
so nothing that was working gets disabled.
Verified: solution build 0 warnings, agent 2897 tests, web 1257 + tsc +
eslint 0, MCP 779, guardrails.json validates against its schema, rules/dist
regenerates without drift. The six new agent tests were green against the
unchanged guard first, which is what proves the exception was inert.
Takes effect on devices only after an agent release: guardrails.json is an
EmbeddedResource in the agent assembly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SHA-256 check proves the ZIP is the one version.json names, but that
manifest lives in the same container as the ZIP: it cannot outlive a
compromise of the download host. The expected publisher travels inside this
script, which the loader verified before running it, so the chain from the
file an administrator assigns to the process that starts is publisher-anchored
end to end.
Install-AutopilotMonitor.ps1 v2.4 -> v2.5:
- Get-UnverifiedAgentBinaries checks every AutopilotMonitor.*.exe/.dll in the
extracted payload for status Valid and the same subject pin the loader
carries; the gate runs immediately before --install and throws on any
rejection, exactly like the hash check.
- Third-party assemblies keep their own publisher's signature and are not
checked: System.Management.Automation.dll ships unsigned, so a gate over all
files would refuse the live payload.
- An extracted folder with no binary of ours is a rejection, not a pass.
- The SHA-256 manifest check is unchanged and still runs first: it proves the
bytes, the signature proves the author.
Verified against the live payload 2.0.1454 (all five of our PEs Valid and
glueckkanja AG, gate passes); appending one byte to AutopilotMonitor.Shared.dll
makes it reject. Pester 55/55.
The portal privacy and about pages carried the pre-signing wording and are
corrected in the same change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quiet third line in the panel header ("Latest agent version 2.0.1452",
faint, the number monospaced) reads from the existing config/latest-versions
endpoint. The monospaced string is the same one the session list shows in its
"Agent Version" column, so a reader can tell at a glance whether their fleet
runs the latest agent.
Portal and signed-in only: the endpoint is AuthenticatedUser, and the public
site has no session list to compare against. The gate sits on the mount of the
small component, so the landing page never even issues the request; a missing
version renders nothing.
A tag is a mutable pointer owned by the action's publisher. These jobs are not
ordinary CI: they hold the code-signing Key Vault token (D-223), the federated
storage credentials (D-222), and combine-rules.yml holds a contents:write token
that pushes to main. Whoever could repoint v7 would run code with all of that.
azure/login and Azure/functions-action were already pinned; the actions/* and
github/codeql-action references were not, on the usual argument that GitHub owns
them and shares the runner's trust boundary. That argument does not survive the
fact that they run in the same jobs as the signing identity. All seven are now
pinned by SHA with the version in a trailing comment -- the comment is what keeps
the line reviewable, and Dependabot rewrites both together.
action-pins.yml is the guard: pins nobody enforces rot, and no review reliably
catches one @v4 added to a 200-line workflow. It lives in its own workflow because
ci.yml deliberately ignores changes to the other workflow files, so a job placed
there would stay silent for exactly the commits it must inspect. Like CI it reports
rather than blocks -- main is unprotected; making it binding would mean adding it
as a required status check, which is a branch-protection decision.
Verified both directions: 49 references pass against the repo, and a fixture with
@v7, @main and a bare SHA fails with three annotations while the local reusable
workflow refs are skipped.
Two floating tool versions closed while here. wix went from the range 5.* to 5.0.2
-- it writes the bytes of the MSI customers install, where AzureSignTool only signs
them. ajv was the worse one: unpinned, and npm runs lifecycle scripts at install,
so a hijacked release would have executed with the push token before a single rule
was validated; now exact versions with --ignore-scripts.
Line endings: sed -i rewrote the workflow files as LF. Git normalises on commit, so
the content diff is unaffected and indexnow-notify.yml carries no change at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The portal showed the two-stage bootstrap output ending in ...[truncated]. That marker
comes from the agent, not from the web view: the stdout is cut at capture time, so no
amount of UI space brings the rest back.
Measured over 1,486 script outputs in the fleet (2026-08-25..09-08): median 39, p90 644,
max 1,501 chars, and the 2,048 cap had never been hit -- the maximum was our own
one-stage bootstrap. The loader preamble of ~460 chars pushed exactly that output over
the edge, i.e. this is a consequence of D-224 and it hit the one output an operator
actually reads.
8,192 covers the realistic worst case (download retries plus relax logging, ~2,700) and
stays 4x below the 32K-char Table Storage property limit that bounds the event
downstream. Other scripts are unaffected in practice: p90 is 644 chars.
Reaches the fleet with the next agent release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blobs carry Content-Type: text/plain and no Content-Disposition, so every browser
renders them inline -- the documented "download the bootstrapper script" step turns into
"select this text and save it yourself", with whatever the editor then does to the line
endings and the signature block.
x-ms-blob-content-disposition makes the link a download in every browser, which is a
server-side fact rather than a per-browser hint in the docs. Command-line clients (the
loader, the MSI runner, curl) ignore the header, so nothing on the device path changes.
Metadata only: the script bytes are untouched, so no version bump and the bump guard
still sees the published copies as current.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The full subject of an EV certificate is a line of street address and registration
numbers and pushed the useful part of the log line off the screen. The CN answers the
question the log has to answer. The full subject is still logged in the one case where it
is the finding itself: a valid signature from a publisher that is not us.
Loader 1.1 -> 1.2; a Pester case pins the short form. Suite on PS 5.1: 45/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bootstrap-loader.log is new with the two-stage bootstrap and told the diag tooling
nothing so far. Names which channel a device was assigned: the loader logs there when
assigned directly, and to bootstrap-msi.log when it runs from the MDM-LOB MSI.
Takes effect with the next MCP deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Invoke-BootstrapDownload.ps1 and Start-AutopilotMonitor.ps1 had become the same script:
download the current installer, run it, log, always exit 0. The MSI copy additionally
downloaded and executed WITHOUT verifying what it got, which is exactly the gap the loader
was written to close. The MSI is now packaging only -- it installs and runs the same file
that is assigned directly in Intune, and both delivery channels get publisher verification
from one implementation.
Dropped from the MSI path: its own Deployed-marker check. That guard lives in stage 2 and
runs there anyway, so the only cost is one download on a device that is already deployed.
Kept: the log file name. bootstrap-msi.log is what the diagnostics tooling keys on to tell
the MDM-LOB channel apart from the Intune script channel, so the loader takes -LogFileName
(default bootstrap-loader.log) and the custom action passes bootstrap-msi.log. A Pester
test now pins that contract across the two files -- renaming the parameter on either side
would leave the MSI channel logging under the wrong name.
Package version 1.0.2 -> 1.1.0 so Intune LOB update detection sees the new payload, and a
new component GUID because the key path file changed.
Loader 1.0 -> 1.1. Bootstrap suite on Windows PowerShell 5.1: 44/44.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/ at the repo root is the customer surface: everything under
Bootstrap/ and CustomerSetup/ is published to the download container by
Publish-BootstrapScripts.ps1, Authenticode-signed and version-gated. The
error-code generator is a maintainer tool no workflow runs, so it diluted
that invariant. .github/scripts/ was not the alternative -- it holds only
helpers a workflow step invokes, and unlike guardrails-in-sync and
shared-manifests-in-sync the catalog has no CI drift gate (the generator
needs IME_ENUMS_JSON from operator tooling and live MS Learn fetches).
REPO_ROOT stays ../.. at the new depth, so only the documented path moved:
the generator header and README, the C# and TypeScript comments, the test
summary, and the description header of error-codes.json together with the
generator literal that emits it (the two must stay identical or the next
regeneration diffs). The web sync re-ran clean at 607 entries with no
change to utils/error-codes.json, which drops the header.
MSI carries an Authenticode signature like any PE, so the same identity and the same
ordering rules apply: signing identity logs in first and only for the Key Vault token,
the storage login stays last, and no environment: on the job.
The embedded Invoke-BootstrapDownload.ps1 is signed BEFORE wix build -- wix embeds the
file as it finds it, so signing afterwards would leave the copy inside the MSI unsigned,
and a device under an AllSigned policy needs the runner to carry its own signature.
The MSI is signed after the build and before the hash and the upload, then both files are
verified (Valid, timestamped, optional publisher pin) while nothing is published yet.
The provenance attestation now also skips runs that upload nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Public repo: keep the reasoning that a later change could break -- signing must precede
the release hashes, the job must never carry an environment, the timestamp is not
optional -- and drop the rest. Comments only, no behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ',' operator elsewhere in this file exists because PowerShell unrolls an array
RETURNED from a function; a property assignment does not unroll, so it wrapped the
byte[] in an Object[] of length 1 and the upload failed converting it back. Signing
itself was fine -- run 34253331703 signed all six scripts and uploaded none, so nothing
reached the download alias.
Added the invariant that would have caught it: signed bytes must be longer than the
unsigned form, since a signature only ever adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Start-AutopilotMonitor.ps1 is the new stage 1 -- the file an administrator assigns in
Intune. It carries no enrollment logic: it downloads the published installer, verifies its
Authenticode signature, and runs it in the same process, passing through every remaining
argument. Assigning the installer directly stays supported and unchanged.
The point of the split is that the customer-held artifact stops changing. Today every
bootstrap change forces customers to replace their Intune copy, so the fleet runs a
distribution of script versions we do not control. It is also what makes script signing
worth doing: signing a file that gets swapped by hand each release is a one-off gain,
signing a loader that never changes is permanent.
The loader is deliberately minimal, because it is the one piece that cannot be fixed
remotely -- every guard placed in it would be frozen for years. All guards stay in stage 2.
Verification pins the certificate SUBJECT, never a thumbprint: certificates get renewed and
a deployed loader cannot be, so a thumbprint pin would strand the fleet at the next
renewal. A hash oracle was rejected for a different reason -- it would have to be fetched
from the same host as the script, so it could not survive a compromise of that host, while
the expected publisher travels inside the loader.
Fail-closed on verification, fail-soft on exit: nothing runs without a valid signature from
us (running unverified code as SYSTEM is the risk being removed here), but the loader still
exits 0, because a device without monitoring is acceptable and a failed enrollment step is
not. The accepted cost is that a device which can never fetch or verify stays silent.
Publisher changes:
- every published script is signed (loader, installer, both -Dev renders,
Test-ShouldBootstrapAgent, Grant-AutopilotMonitorAddOn), and signing is the LAST
transformation: it covers exactly the CRLF/UTF-8 bytes Get-PublishBytes produced, so
nothing may normalise or re-encode them afterwards.
- signing is mandatory for a real publish. Only -DryRun may run unsigned, because the PR
gate executes the publisher without any Azure login.
- comparisons against a published copy strip the signature block first. A timestamped
signature differs on every run, so without stripping the version-bump guard and the
drift check would report a change for every unchanged script.
- the bump guard became Assert-VersionBump and now covers the loader as well.
- version.json gains loaderVersion. Additive, no consumer yet: stage 2 is current by
construction, so only stage 1's version says anything about a customer's Intune copy.
- -SignedOutputDir writes the exact uploaded bytes for the CI artifact, in a dry run too --
that is how a signed script gets inspected before it reaches anyone.
publish-scripts.yml logs in with the signing identity first and only to fetch the Key Vault
token (azure/login sets the default az account and the storage login has to be the one that
stays), then publishes and offers the signed scripts as an artifact. build-agent.yml passes
the same parameters on a stable cutover.
Tests: 14 new Pester cases, including that a validly signed script from a FOREIGN publisher
is refused -- that is what an attacker with any code-signing certificate can produce. Full
bootstrap suite on Windows PowerShell 5.1: 43/43.
Not covered: the MSI runner still downloads stage 2 without verifying it, and the customer
docs still describe the direct assignment only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dispatch with upload_blob=false and create_release=false publishes nothing, so the
Sigstore attestation would write a permanent public transparency-log entry for bytes
nobody ever receives. The step now runs only when the run actually ships something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every assembly we author -- AutopilotMonitor.Agent.exe, AutopilotMonitor.SummaryDialog.exe
and the three AutopilotMonitor.*.dll beside them -- is signed with AzureSignTool (pinned
7.0.1) against a certificate in the corporate code-signing Key Vault, SHA-256 file digest
plus an RFC-3161 timestamp. Third-party assemblies keep their own publisher's signature.
The position of the step is load-bearing: signing rewrites the PE file, so it has to run
before the staging copy, the ZIP and every SHA-256 in "Create release artifact". Those
hashes are the bootstrap's integrity oracle (version.json, AdminConfiguration) -- signing
after hashing would publish hashes that no downloaded file can ever match.
The signing identity is a SECOND federated principal in the same tenant and logs in FIRST,
only to fetch a Key Vault token; the storage login of D-222 follows and owns the az context
from there, because azure/login sets the default account and the last login wins. Both
federated credentials carry the subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main,
so this job must never be given a GitHub 'environment:' -- that rewrites the OIDC subject
claim and would break BOTH logins at once.
No unsigned fallback and no dispatch input to skip signing: a missing repository variable,
a Key Vault failure or a non-zero azuresigntool exit code fails the run before anything is
published. azuresigntool is a native tool, so $LASTEXITCODE is checked explicitly --
PowerShell does not throw on a non-zero exit, and a silently unsigned release is exactly
the outcome signing exists to prevent.
Verification runs against the STAGED bytes, not the build output: every AutopilotMonitor.*
PE in the ZIP must be Valid, must carry a timestamp (without one the signature dies with
the certificate) and all of them must share one signing certificate. Setting the optional
repository variable CODE_SIGN_EXPECTED_SUBJECT additionally pins the publisher, so the gate
proves "signed by us" instead of "signed by someone trusted".
The build provenance attestation now covers the ZIP and both EXEs, so an extracted binary
can be verified without the ZIP. Authenticode and the attestation answer different
questions and both stay: who published this binary (offline, what Windows, SmartScreen and
WDAC evaluate) versus which build produced these exact bytes (gh attestation verify).
Not covered here: the customer bootstrap script (its signature block changes the published
bytes and must follow the dev render; see B-57c, the two-stage loader), the bootstrap MSI,
and the local build.ps1 path, which from now on produces unsigned bits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every storage write against autopilotmonitoreu -- build-counter CAS, agent ZIP and MSI,
version manifests, customer bootstrap scripts, the AdminConfiguration hash oracle -- now
rides on the existing federated credential (subject repo:.../ref:refs/heads/main) instead
of a long-lived SAS in repo secrets.
The REST calls themselves are unchanged: '?<sas>' became an Authorization header, so the
ETag-CAS of the counters, Content-MD5 and the no-cache contract survive verbatim. That is
also why this is not a rewrite onto 'az storage blob upload' -- it cannot express If-Match,
and two concurrent builds could then mint the same build number.
.github/scripts/Get-StorageAuthHeaders.ps1 is the single place that knows the resource id,
the az invocation and the service version. x-ms-version is mandatory for Entra-authorized
requests (>= 2017-11-09) and for any Table request without a SAS; 2021-08-06 was verified
live against both the blob and the table endpoint of this account.
azure/login added to build-agent, build-msi, publish-scripts (plus id-token: write) and
deploy-web; deploy-backend and deploy-mcp already had it. Publish-BootstrapScripts.ps1
-DryRun acquires NO token: the PR gate runs it without any Azure login, and that must
stay true.
The AdminConfiguration merge lost its "secret not configured" skip branch -- a failure
there now surfaces instead of silently leaving the hash oracle pointing at an older build.
AZURE_BLOB_AGENT_SAS_TOKEN, AZURE_BLOB_VERSIONS_SAS_TOKEN and AZURE_TABLE_ADMIN_SAS_TOKEN
are retired once the first runs are green. AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN stays: that
account lives in the pre-migration tenant, where this principal has no identity -- it dies
with the mirror, not before.
Requires on the SP: Storage Blob Data Contributor on the containers 'agent' and 'versions',
Storage Table Data Contributor on the table 'AdminConfiguration'. Granted 2026-09-08.
Title and trailing controls shared one non-wrapping flex row with the controls
marked flex-shrink-0. At 440px (iPhone 16 Pro Max) that squeezed the Operational
Events title into an 86px column and pushed the Refresh buttons of Agent Metrics
and the Tenant Config Report past the viewport, scrolling the whole page sideways.
SectionCardHeader now stacks below sm, which covers all 32 cards that use it.
A width sweep over the portal routes at 440px found two more real overflows, fixed
here as well: the audit From/To date pair, and Alert Rules, where the unbreakable
event-type names and a channel-name input without min-w-0 pushed the severity
selects 99px off screen.
Verified: every swept route now measures zero horizontal overflow at 440px, desktop
is unchanged at 1440px. The header test asserted "no trailing group" via the absence
of gap-3, which the wrapper itself now carries; it checks flex-wrap instead and pins
the stacking classes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card composed its line from the backend's explanation ("did not answer within
3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a
starting instance needs. It now writes its own one-liner and ignores check.message,
so the server's "warming" and a client-side probe timeout read identically and the
wording does not depend on a backend deploy. The backend message is shortened but
still carries the budget for direct API callers.
The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps
activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test
pinning that the stated seconds come from cadence x attempts.
Measured over 14 days, GET health/mcp answered in 4..148ms when warm and timed out at 30s
in 50 of 83 calls — the median call took 27,972ms. A cold start is 20-30s of Container Apps
activation (~98% KEDA plus image mount, ~500ms application), so no UI wait covers it and the
generous timeout only bought a long wall in front of the same answer. The browser's own
budget is the same 30s, so the backend's carefully worded warning never reached the user at
all: they saw a raw DOMException.
Backend: the budget drops to 3s, which sits in the widest observed latency gap
(2347ms..3930ms) so jitter cannot flip the verdict. Our own cancellation now reports the new
status "warming" at Information level (with minReplicas=0 a cold probe is the expected path,
and logging it as a warning poisons the ops signal); HttpRequestException is split out of
that catch and stays a real warning, still without the host in the message. The warm-hit
threshold drops to 750ms — ~5x the warm maximum, deliberately generous because every
measurement comes from a near-idle single-user system and warm latency will rise under
concurrency — and moves into McpReachableMessage so a test can pin the boundary. The XML doc
now describes the probe that exists rather than the wake-signal design the measurements
disproved.
"warming" is a new status value rather than an added DTO boolean because the backend deploys
before the web: an unknown value degrades to a grey card with a clean banner, whereas a
boolean would leave old web showing yellow and "Warnings detected" on every cold start.
HealthCheck.Status now documents the five-value vocabulary, which rides into both generated
TypeScript mirrors as JSDoc.
Web: resolveMcpCardState owns display, banner rating and the poll decision as a pure
function (the suite runs on node, so testable logic cannot live in JSX). A warming server
re-probes every 5s for up to 8 attempts and stays out of the overall banner exactly as
"unknown" already does — scaling from zero is expected behaviour, not a degraded platform —
and only becomes a real warning once the re-polls are spent. The card keeps showing
"warming" while the next probe is in flight so it cannot flicker, carries the blue
in-progress look instead of yellow, and was hoisted out of the healthResult guard: a failing
detailed check would otherwise hide a card that is still polling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column showed high/medium/low under the header "Identification", which reads as a method rather than a rating. The wire field is `confidence` and the tooltip already says "identification confidence"; the header and legend now match. The analyzer toggle and its hover border were still emerald from before the section moved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The portal hides Delete for owned (msp-{home}) groups, but DELETE global/tenant-groups/{groupId} accepts any id: a Global Admin deleting one ended Pro (MSP) for every member with no grace anchor, so a member that raised its retention under Pro was clamped to the Community cap at the next sweep. DeleteGroupCoreAsync now snapshots the owner and calls ProConferralService.RecordLossAsync(member, owner, "group-deleted") per member, owner excluded, independent of assignees. Two tests in RevokeEnforcementTests; internal docs list the fourth writer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Act equals the KEV set by construction (CvePriority: KEV => act), so the KEV tile becomes the Act tile and Attend joins it instead of a duplicate count. The breakdown is counted in the backend over every CVE in the window, like the severity breakdown, because the list is top-N. Both panel consumers read the generated CveExposureSummary type; the MCP tool description names the new block. Wire types regenerated for web and MCP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The caller-supplied X-MCP-Tool-Name header went verbatim into the UserUsageLog
row key ({yyyyMMdd}_{tool}:{endpoint}) and from there into the CAS-conflict
warning of TableCasRetry. EndpointNormalizer.ToolNameKey now strips CR/LF and
every other control character or line separator, replaces the characters Table
Storage rejects in keys (/ \ # ?) and caps the length at 64; an empty result
skips the prefix. Real tool names pass through unchanged, so existing usage
rows keep their keys. A header with an illegal key character previously made
every usage increment fail with 400 (fail-soft, counter never advanced).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The manifest committed with d5d1d7bf carried the types section of the previous HEAD, so
the ntpServer doc string still read "null = use agent default"; SharedManifestParityTests
compares byte-for-byte. Generated output only, no wire change. Bumps the internal gitlink
(plan review addendum).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report still carried two hand copies after D-215: a typed map of C#
initializer defaults for the "custom" highlight, and a TypeScript mirror of
GetAgentConfigFunction's resolution rules for the runtime column.
Backend: the derivation moves verbatim into Services/AgentConfigResolver
(Build = pure, ResolveAsync loads tenant + admin config and the two rule
catalogs). GetAgentConfigFunction keeps the kill verdict, the migrate logging
and the device fields on top; ParseAgentMajor, ResolveDiagnosticsUploadEnabled,
MergeDiagnosticsLogPaths and ResolveMigrateTarget move with it (call sites and
tests retargeted; the QueryParams ratchet baseline shrinks by the file). New
GlobalAdminOnly route config/{tenantId}/effective-agent-config returns the same
AgentConfigResponse without a device (optional ?agentVersion=, default the V2
line). AgentConfigResolverTests pin the rules that used to live in the web:
upload 10, Hosted destination without SAS, SAS regardless of mode, unrestricted
only with entitlement + gate + toggle, operator knobs from AdminConfiguration,
analyzer defaults, hash line per major, migrate override and rejection.
Manifest: SharedManifestParityTests emits "defaults" next to "fields" for
TenantConfiguration and AdminConfiguration and an "agentConfig" section with
the AgentConfigResponse / CollectorConfiguration / AnalyzerConfiguration class
defaults (fresh-instance values; nested objects null). TenantConfiguration.
NtpServer gets the initializer "time.windows.com" the table read already
applies, so the manifest default and a stored row agree.
Web: the catalog rows lose value() and default; the runtime column renders
the endpoint's response, the highlight compares against the manifest defaults,
informational rows (reasons, timestamps, identities, secrets) never read as
custom. Previously excluded fields are visible (configVersion, catalog counts,
sealing pattern ids, hash oracle, migrate target); the collector class
defaults sit in a collapsed "Collector Internals" section. Fail-soft: while the
backend is not deployed the runtime column shows a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Health by Device Model" link searched "{Manufacturer} {Model}" as one substring; the server's
free-text predicate tested it per field, so once the failed sessions were outside the loaded first
page the sweep returned nothing.
- lib/searchQueryTokens.ts: tokenizer shared by the timeline and the session search (terms, quotes,
leading minus, key=value / key:value qualifier when the separator precedes any quote)
- app/dashboard/utils/sessionSearchQuery.ts: 15 qualifiers = exactly the fields both sides search,
matcher over session fields + client-derived extra text, suggestion field lookup, link builder
- useDashboardFilters / SessionTable: filter and suggestions use the grammar; fuzzy only for a
single free term; session_searched gains a `qualifiers` count
- Helpers/SessionSearchQuery.cs: the same grammar for q= on /api/search/sessions (+ global), parsed
once per request; MatchesFreeText is a thin wrapper
- utils/session-search-syntax.cases.json: one case file run by vitest and by
SessionFreeTextSearchTests (parity proof)
- FleetModelHealth/FleetSlowModel/FleetFailingModel: manufacturer + model as stored plus label,
grouped on the pair; useFleetHealth uses the generated wire types; "Unknown" is not a link
- shared manifest + wire types regenerated for the three DTOs
Backend suite 6158/6159 (the one failure is SharedManifestParityTests against uncommitted WIP of a
parallel task), web vitest 1177/1177, build 0 warnings.
The progress portal placed the active step purely by the stored phase. After
whiteglove_complete the backend caps that phase at Apps (Device), so the end
user saw "Waiting for you to sign in" above a step list that still showed
"Installing apps (device)" as running at 43 % (customer report).
The seal detection the headline already used is now one helper,
isParkedAfterTechnicianPart (pre-provisioned, whiteglove_complete, no resumedAt
and no whiteglove_resumed), and resolveActiveStepIndex places such a session on
the first user step: every device step done, 57 %, Account setup current. A
stale Complete phase on older sessions can no longer push it past the sign-in;
once resumed the placement is phase-driven again.
Vitest 24/24, tsc and eslint clean. Backlog B-ydz carries the post-deploy check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report typed its own TenantConfig interface and drifted for five months: 31 stored
fields were missing (payingCustomer, the MSP overrides, three validation gates, the SLA
block, four analyzer flags, app-homing and trial bookkeeping) and three runtime values
were wrong (upload interval 30 instead of 10, diagnostics-enabled without the Hosted
destination, unrestricted mode without the entitlement).
tenantConfigReportCatalog.ts is Record<keyof TenantConfiguration> and
Record<keyof AgentConfigResponse | CollectorConfiguration | AnalyzerConfiguration>:
every key is rendered or excluded with a reason, so a new C# property fails tsc once the
wire type is regenerated. Runtime rows carry the GetAgentConfigFunction rule and a source
tag; entitlement values come from feature-flags, operator knobs from global/config.
A vitest pins the same parity against SHARED_MANIFEST.tenantConfiguration.fields and the
resolution rules (upload 10, Hosted destination, flags-driven unrestricted mode, admin
idle timeout, analyzer defaults). Empty strings count as unset for the custom highlight.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Parallel tool calls of one user hit the same (user, day, endpoint) row in the same instant; without backoff the losers stayed synchronised and 3 of 6 increments were lost. Both increments now use the one CAS helper (merge patch, AddEntity on 404, StorageCasConflict marker) with UsageCounterRetries = 8; the tenant counter's attribution columns travel in the merge patch. Tests pin the budget, the patch shape and the create path.
- IME-EXITCODE-CLASS captures the return-code class of the installer exit code ("lpExitCode is defined as <Success|SoftReboot|HardReboot|Retry|Failed>", verified against the IME string literal) as exitCodeClass on AppPackageState; new action captureExitCodeClass, persisted across the agent restart a reboot causes, last value wins across a Retry loop. IME-EXITCODE-MAPPED-FAILED keeps the error path; both match the same line.
- Adapter: exitCodeClass whenever known, exitCode on every terminal state (Installed/Postponed/Skipped/Error) instead of Error only.
- ANALYZE-APP-018 (warning, apps): app_install_completed.exitCodeClass in SoftReboot,HardReboot; +25 HardReboot, +15 observed system_reboot_detected; never fails the session.
- Web: completed-app exit badge amber (failed stays red), "mapped as SoftReboot/HardReboot/Retry" on timeline and Install Progress.
- Tests: pack end to end (phase, current app, class lines, negatives, restart round trip), adapter payloads, linearity samples, backend rule engine + known actions.
- internal gitlink: plan, D-214, backlog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCP: coverage block (window, IME tracker, collectors, upload, diagnostics, gaps) built from the
health events the summary already fetches (session-coverage.ts, data.<key> slices); validate_rule
warns on a required not_exists of an IME-log-derived event without the ime_tracker_degraded
precondition; authoring guide 'absence'; instructions and prompts point at coverage.gaps.
Rules: guardrails.json imeLogDerivedEventTypes (schema + combine). Backend: catalog invariant test —
absence rules carry the coverage gate or a documented exception (ANALYZE-ID-004).
Agent: diagnostics_uploaded/_upload_failed carry packaging counters (truncated, includedFiles,
skippedFiles, skippedByReason, problemsByKind) — counts only, never paths.
Also: shared-manifests.json regenerated (was stale at HEAD; McpAccessPolicy doc string) + codegen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Order at the end of the modal: MCP Access, Tenant Suspension, Offboard Tenant. Rarely used
levers sit together; the frequently edited plan, contact and limit sections come first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TenantConfiguration.McpDisabled + McpDisabledReason (GA-only, same deny-list and PUT guard as Disabled).
Front door: McpUserService denies every caller of a closed home tenant below the platform roles.
Per request: PolicyEnforcementMiddleware denies a closed home or target tenant for X-Client-Source: mcp
(403 McpDisabled) and narrows the delegated fleet bound, never to null. ClientSourceHeader is the one
reader of the header. The MCP access guard surfaces the backend reason verbatim. GA tenant modal,
list badge and config report; wire types regenerated; internal doc, health marker, gitlinks bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A fixed overlay still receives its parent's box-model margins: the tenants page's
space-y-8 wrapper gave the edit dialog's backdrop a 32 px margin-top, leaving the
navbar un-dimmed. All 21 full-screen overlays now portal to document.body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.
What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.
Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The eight backend POST/PUT/PATCH bodies name their request DTO through
jsonBody<T> from client.ts — the one place a body is encoded — so a renamed
or retyped backend field fails tsc instead of surfacing as a 400 at runtime.
The apiFetch guard additionally refuses `body: JSON.stringify(` and a bare
`jsonBody(` with an empty baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).
Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).
35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).
TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The surface comes from the signed appidacr/azpacr claim (0 = public client = portal;
secret/certificate, app-only or missing = integration), never from a header: portal SPA,
MCP server and API share one app registration in production, so the client id cannot tell
them apart and any token holder controls headers. New AdminConfiguration field
PortalUserRateLimitRequestsPerMinute (default 600, Global Settings round-trip, wire types
regenerated); the existing user budget is relabelled MCP & integrations and keeps the tenant
override and the Pro floor; buckets are keyed per surface; ThrottleSurface is a request
dimension next to ClientSource so the classification is auditable (platform-health check 2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A freshly onboarded tenant spent 45 minutes in 503 because a second instance held a
roleless app-only token minted before the consent; InvalidateTenant never reaches other
instances. GraphAuthFailure is the shared step for all five Graph validators: attempt 1
drops the cached token and reports transient so the existing loop re-mints; attempt 2
keeps each validator's semantics (Autopilot/DeviceAssociation transient with Retry-After
120, CorporateIdentifier/CloudPc/IntuneBinding definitive) and logs a stable marker.
SecurityValidator hands the agent the largest Retry-After requested. Platform-health
check 6 and the marker catalog know both markers; the cloudpc concept doc carries the
contract.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BackupJobStatus, RestoreRowPreviewResponse and RestoreRowCommitResponse were the last response payloads the
portal typed by hand: the C# classes existed in Shared but were not marked [WireContract], so the manifest
never carried them. The attribute pulls them and their enums (BackupJobKind/State, BackupOutcome,
RestoreRowDiffKind, RestoreRowCommitOutcome) into shared-manifests.json and both generated wire-type files;
the three backup pages import those and lib/api.ts drops eleven hand-written mirrors. Attribute only: no
runtime behaviour changes, no backend deploy needed.
Still hand-written in lib/api.ts, with reason: BackupManifest/BackupTableEntry (a blob passed through by
GET global/backups/{id}), BackupTriggerResponse (the 202 body of global/backups/trigger is an anonymous
object outside the typed regime) and the RestoreRow request body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five hand-written customs-archive response types in lib/api.ts duplicated CustomsArchiveRunListResponse,
CustomsArchiveEntryListResponse, CustomsArchiveRunSummary, CustomsArchiveEntrySummary and
TenantOffboardingCustomsArchiveEntry from the manifest; the two pages import those. The annotations page
built its next-page URL from API_BASE_URL + nextLink instead of the scoped builder plus extractContinuation.
PaginatedResponse<T> had no importer. types/diagnostics.ts states why it stays hand-written (no wire DTO
for GET diagnostics/paths).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rules pages, apps tabs, audit, delegations, diagnosis, fleet health, geographic, health check, SLA, usage
metrics, the notification and SignalR contexts, the feedback bubble, global search and the shared hooks
now call fetchJson/fetchOk; the activation page's hand-rolled Bearer fetch (no 401 retry, no
telemetry) joins the layer too. The SignalR join/leave paths keep their visible-refusal semantics through
ApiError, the delegation acceptance explains refusals by code, the health check keeps its 403 wording,
and the local mirrors of SlaMetricsResponse and PlatformUsageMetrics give way to the generated types.
No page, component, hook or context calls authenticatedFetch directly any more. What stays is
documented per file in the baseline's PERMANENT map: the auth/me bootstrap, the SWA's own version.json,
the public stats manifest, the delete-cascade classifier's 202 body, and seven TokenExpiredError
branches where the UI must act differently from showing the message (route, rethrow, batch, retry flag).
Ratchet baseline: 39 → 0 raw fetches, 40 → 7 parses, 25 → 8 token checks (from 228 / 221 / 140).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
About 12 MB/day of severityLevel-1 traces without diagnostic value (MSAL verbose, StatusCodeResult,
CORS success, Application started). Application logs were Warning+ already via Function: Warning.
Measured and documented in internal/docs/backend/telemetry-ingest-shaping.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 33 raw authenticatedFetch sites under app/sessions, app/dashboard and app/progress are gone. Hooks
that receive addNotification as a parameter report failures through notifyApiError (same rendering as
notifyError, injected notifier); fail-soft reads that used to check res.ok read through
.catch(nullOnApiError). The delete-cascade classifier accepts the ApiError of a refused DELETE next to
the 202 Response, so the 409/503/404 branches keep their wording; the polling probe reads the status
from either. Session and event reads name their wire types (GetSessionResponse, GetSessionEventsResponse,
SearchSessionsResponse, SessionStatsResponse, the progress and rule-stats responses); the legacy
list-shaped fallbacks on the single-session read are gone. SessionTimeBreakdownDto is the generated type.
Ratchet baseline: 72 → 39 raw fetches, 67 → 40 parses, 36 → 25 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 46 raw authenticatedFetch sites under app/settings are gone: 20 by the transformer, the rest by hand.
The tenant config load keeps its 403 fallback to the feature-flags view through ApiError; the access
check treats a backend refusal as an outcome and only a failed round trip as transient; delegated
access, MCP users and the offboarding flows read their specialised envelopes through ApiError.code
and ApiError.body. Twenty 'Session Expired toast / else inline' catch dialects collapse to the inline
message apiErrorText already renders; local mirrors of McpUserEntry, PlanTierDefinition,
HardwareRejectedResponse and DeviceNotRegisteredResponse give way to the generated types.
Ratchet baseline: 115 → 72 raw fetches, 111 → 67 parses, 59 → 36 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- StorageClientOptions: Table/Queue 8 s per attempt × 3 attempts + capped backoffs = 25.8 s < the
agent's 30 s upload timeout (pinned by test); Blob 60 s × 4. Every production client takes its
options from there. No Polly over the SDK.
- StorageErrors.IsTransient(Exception): OperationCanceled / IO / HttpRequest / Aggregate-of-those
/ RequestFailed status 0; the ingest answers 503 + Retry-After instead of 500.
- The ingest still takes no caller cancellation (a batch is a unit). GetAllSessionsPageAsync
carries the request token down to BoundedFanOut (Global Admin list); SingleFlightCache hands its
factory a 30 s budget token and evicts on timeout (tenant ids, app-summary snapshot, session stats).
- ProcessStallMonitor: 1 s tick drift ≥ 1 s ⇒ ProcessStall event (sampling bypass) + metric with
cause gc / threadpool / unknown from GC.GetTotalPauseDuration and thread-pool counters;
GcPauseMs on request rows ≥ 100 ms. Measures the instance-local 5–10 s pauses seen in production.
- Gitlinks: internal (plan, D-203, concept doc, B-9hf, lesson), .claude/commands (marker rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 113 raw authenticatedFetch sites under app/admin are gone: 73 by the transformer (fetch + ok-check
+ json parse become one typed call), the rest by hand — 404-as-empty lists read through
.catch(nullOn404), the app-homing, offboard, delegated-admin and tenant-group flows read the
specialised envelope through the new ApiError.body, downloads use fetchBlob, action calls fetchOk.
Session-expired branches that only logged or set the same message are gone; apiErrorText renders
every failure with the reference.
Typing the responses surfaced eight local mirrors that had drifted from the wire types (required
fields the backend leaves optional): BlockedDevice, FeedbackEntry, DistressReport, OpsEvent,
SessionReport, IdentityBinding, UnmatchedSoftwareEntry, DailySummary and the whole
SectionPlatformUsage metric set are now aliases of the generated types, with the optional fields
handled where they render. AdminConfiguration keeps its literal-union narrowing through
fromWireAdminConfiguration instead of an unchecked parse.
Ratchet baseline: 228 → 115 raw fetches, 221 → 111 parses, 140 → 59 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
apiFetch takes a mandatory wire type (60 call sites name their generated DTO; the 28 as-casts
and 31 unknown results are gone) and refuses an empty or malformed 2xx body. It retries once on
429 (never the MCP quota) or 503 when Retry-After is at most 10 s — GET only, never with a
caller-supplied signal, opt-in via retry:true otherwise — and counts the retry on the tool-call
context, which withToolTelemetry writes as `retries` on the tool_call line. ApiError.parsed is
the generated ApiErrorResponse plus domain fields and carries retryAfterSeconds; toolError
renders it in the 503, envelope and rate-limit branches. The lean-projection default lives in
one helper (leanFieldSelection) instead of two copies.
apifetch-typed.guard.test.ts refuses a bare apiFetch( or apiFetch<unknown>; apifetch-retry.test.ts
pins the retry, the opt-outs and the body contract. tools-delegated fakes now answer text() with
the body they claim (the client parses text, not json).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first,
Retry-After header second), fetchJson<T> refuses empty and malformed 2xx bodies instead of
handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and
fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice;
apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the
"Session Expired" dialect into one place and NotificationContext.notifyError uses it.
useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch
and onTokenExpired. dedupedFetchJson<T> for the three feature-flag readers.
lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/
.json()/instanceof TokenExpiredError per file against a baseline that may only shrink
(a count above fails as a new site, below as a stale entry); fetchJson without a type
argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent uploads arrive as bursts of batches ~0.3 s apart with seconds of silence in
between, and every batch raises a SignalR signal. The 300 ms trailing debounce on the
session page either refetched once per batch or hid a tight burst until it paused;
session 46096bcd produced 155 full first-page reads for 261 batches in 13 minutes.
lib/burstScheduler: leading edge + trailing debounce + max wait. The first signal after
a quiet period refetches at once, later signals of a burst refetch once after the
burst's last signal, and a burst that never pauses still refetches every max-wait
(spool replay after reconnect/reboot). Fetch semantics are unchanged: every run reads
the full first page, the server stays the single source of truth, no incremental path.
Session page: trailing 300 ms, max wait 1 s. Progress portal (same pattern, two
requests per refresh): trailing 500 ms, max wait 1.5 s. scheduleFetchEvents loses its
delayMs parameter; the scheduler is created lazily on the first signal so refs stay
out of render (react-hooks/refs). Seven fake-timer tests pin the timing rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Helpers/QueryParams replaces fourteen private days/limit parsers, the three
TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites
under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage
or below-minimum means the call site's default, above-maximum clamps. Strict
(TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400
with a message that names the parameter. Integers are invariant-culture, instants are
invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent.
Defaults and ranges stay arguments at every call site.
Settled differences: the Apps windows clamp above 365 instead of falling back to 30,
days=0 or negative yields the default everywhere, the two global geo endpoints and
the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths
on the HTTP layer, a whitespace date means no filter on every route, the admin batch
sizes reject values above their maximum, and the three culture-dependent
DateTime.TryParse sites parse as UTC.
QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline:
headers, raw cells, the agent version major, the se= of a stored SAS URL);
QueryParamsTests carries the cases the removed copies pinned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TelemetryItemKind moves to Shared; TelemetryItem.ToWire() maps onto TelemetryItemDto
and the uploader serialises the DTO, so both ends share one wire class. The spool line
keeps writing Kind by name (StringEnumConverter registered in TelemetryItemSerializer).
IngestTelemetryFunction.PartitionBatch routes by exact kind name and collects every
item it cannot route or parse; a non-empty rejection list answers the batch with 422 +
TelemetryItemsRejectedResponse (poison, rejectedRowKeys, reason) before anything is
persisted, logs a Warning and writes the new TelemetryItemsRejected ops event. The
agent's existing poison protocol drops exactly the named RowKeys and re-uploads the
rest; before this the ingest skipped the items, answered 200 and the agent cleared
its spool.
Contract tests from both sides: TelemetryWireContractTests (agent) freezes the real
batch body under tests/fixtures/telemetry-wire/ and parses the pinned poison body;
TelemetryWireFixtureTests (backend) dispatches every frozen body with zero rejections,
requires an arm per kind and pins the poison body.
Agent-facing responses implement IApiResponse and go through ResponseHelper, request
types carry [WireContract]; plain ingest refusals use the error envelope, the
register-session and upload-url refusals keep their DTO bodies (the agent reads
ErrorCode/Message). Manifests and generated wire types regenerated (also picks up
four types HEAD had left out of the manifest).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PlatformStats(global,current) took a GET plus a full-row upsert without ETag
from every ingest batch: increments were lost under concurrency and a writer
clobbered fields another writer had just changed. RuleStats put every tenant's
daily rule traffic into one date partition (1,459 rows on 2026-09-04), the
writer issued four serial calls per evaluated rule without ETag, and the
reader was a cross-partition scan whose 10k cap dropped the newest dates.
- D-198: TotalEnrollments / SuccessfulEnrollments / TotalEventsProcessed have
no hot-path writer; the two-hourly recompute is the only source. Only
IssuesDetected still increments, through TableCasRetry (ETag + field-only
merge, AddEntity for a missing row, conflicts as StorageCasConflict). The
tenant counters share the helper.
- D-199: RuleStats PartitionKey "{scope}_{date}", RowKey ruleId.
RecordRuleStatsAsync merges a session's increments per rule, reads the
partition once and writes one transaction (UpdateReplace with ETag / Add),
retrying on 412/409. Readers are PartitionKey ranges per scope; aggregation
and the regression radar fan out over the registered tenants; the cleanup
deletes per scope. RuleStats joins the offboarding composite wipe.
- Legacy rows (PartitionKey = date) are read by an extra query and cleaned by
a date range that checks every row for a bare-date key, until 2026-12-05.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- DiagnosticsBlobStreamer disposes the download result on every exit path
(it was disposed only on the 413 branch), measures open and transfer
separately and fires DiagnosticsDownloadProxied after the copy with
Completed=true/false.
- UserDelegationKeyCache: one user delegation key per process (24 h, re-mint
under a lock with 2 h margin or when a SAS would outlive it) instead of one
storage round-trip per download or upload SAS.
- KevDataService is resolved as one singleton over the typed-client handler
pipeline; the cache warmer used to hydrate a throwaway transient instance
while the correlation service held a cold one.
- Admin and tenant configuration services single-flight concurrent cache
misses (Lazy<Task> per key); the MemoryCache and the in-place projection
semantics stay untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dependency filter dropped every successful storage call and the expected
412/409 outcomes, so storage-side latency and ETag contention were invisible
until a call failed outright (seven days of App Insights held zero storage
dependency rows).
- StorageDependencyFilterProcessor keeps a successful storage call at or above
2 s and stamps SlowStorage=true; the operation name is on the row, the table
name is not (the Azure SDK span carries neither URL nor table).
- StorageMetrics wraps TelemetryClient.GetMetric("StorageCasConflict",
operation, table, outcome): pre-aggregated per minute, bypasses the worker's
adaptive sampling, null-tolerant for tests.
- The CAS loops report conflicts: session event count, session counter
reconcile, session store, tenant stats, MCP user/tenant usage.
- TableStorageService keeps its two-argument constructors (Moq matches by
exact arity); DI resolves the three-argument overload.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every entity-group transaction was chunked by count only; 100 payload-heavy rows
(Signals with chunked PayloadJson, Events with 30k-char Message + DataJson) could
exceed the 4 MiB transaction cap, and the failure surfaced as an opaque 500. The
agent halves its batch only on 413 and treats 5xx as "replay later", so such a
batch wedged the device forever.
- TableTransactionBatcher: 100 actions, 3.5 MB per transaction, 1 MB per entity,
never across partitions; an entity that cannot fit throws
TableEntityTooLargeException. Used by the event, signal, transition, restore
and rules writers; delete-only batches keep their count chunking.
- StorageErrors: one classifier for RequestFailedException (transient 408/429/
5xx, payload-too-large by status or error code, already-exists incl. the
Azurite 400 shape); the two IsAlreadyExistsStatus copies use it.
- Event writer: per-row fallback only for data-shaped rejections; throttling,
outages and oversize batches propagate. Single and batched writer share one
entity builder.
- IngestTelemetryFunction: payload too large -> 413, transient -> 503 with
Retry-After: 5, anything else 500.
- TableStorageChunking.MaxPropertyChars is the one 30,000-char bound.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five cross-session index tables (SessionsByTerminal, SessionsByStage,
DeadEndsByReason, ClassifierVerdictsByIdLevel, SignalsByKind) were write-only
since M5.d: no endpoint, MCP tool or web page ever read them, while the
dual-write cost a serial queue send per signal/transition on the ingest path,
a 5-13 min full scan of Signals + DecisionTransitions every two hours and five
property-filter scans per session deletion (audit 2026-09-05 F05).
Removed: the tables from TableNames.All (schema hash changes, next cold start
runs a full pass without them), the telemetry-index-reconcile queue name,
producer, envelope, handler, queue function and 2h timer, the cross-partition
Timestamp scans on the signal/transition repositories, the EnableIndexDualWrite
flag (model, config mapping, admin UI, generated wire types), cascade steps
11-15 (later steps renumbered 11-18), the deletion wrappers and the offboarding
wipe entries. DiscriminatorPkProp stays as a step class so manifests written
before this change still resume.
Order of operations: deploy first, then delete the queue pair and the five
tables in the portal (B-88h). Deleting the tables before the deploy 500s every
delete preview/enqueue and poisons offboarding.
Submodules: .claude/commands (import_session.py), internal (D-196, docs, plan).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Audit 2026-09-05 F01/F02. Analyze, VulnerabilityCorrelate, TenantAutoApprove,
IndexReconcile and ImeMsiArchive are now host-managed queue functions under
Functions/Queue bound to the DataStorage connection (identity-based, resolved
by the host): the Flex scale controller wakes an instance for a message, the
host renews the lease and owns the retry/poison ladder. The four cascade
workers with lease/CAS state stay self-polling BackgroundServices; their base
now receives one message per poll so waiting messages can no longer expire
under another worker. Terminal enrollment alerts and hardware-rejection alerts
are enqueued on notification-dispatch (channel ids only, resolved at send
time) instead of fire-and-forget sends the host could drop on scale-in.
Requires the app setting DataStorage__queueServiceUri before deploy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
withToolTelemetry mints a UUID per tool call and runs the handler in a
ToolCallContext (tool name + correlation id); apiFetch stamps it as
X-Correlation-ID next to X-MCP-Tool-Name, so every backend request of the call
(pagination scans included) and the MCP_TOOL_LOGGING tool_call line carry the
same id — a row-level join between the MCP log and the backend's request rows.
ApiError reads the X-Correlation-ID response header (body correlationId as
fallback), and the error handler names it in every branch — 5xx, quota 429,
structured 4xx and the unstructured 401/403/404/429 texts that had none. The
handler reads the envelope's `code` (errorCode as the pre-envelope fallback),
the quota body's `error`, and the log proxy's `upstreamCode`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
authenticatedFetch mints one UUID per logical call (kept across the 401 retry,
where caller headers now survive too), sends it as X-Correlation-ID — the id
the backend echoes, stamps on its request row and writes into every error body
— and tracks api_request_failed {path, method, status, correlationId} for any
final status >= 400. App Insights gets enableCorsCorrelation + W3C
distributedTracingMode scoped to the API host, so portal dependency rows and
backend request rows share an operation_Id.
fetchJson/ApiError read the envelope (error, code, correlationId, hint; the
pre-envelope `message` as fallback), describeApiError renders message +
short reference, addNotification takes an optional reference the bell renders
as "Ref …"; the error boundaries show and track Next's digest. Consumers of
renamed fields follow: delete-session parser (code ?? hint, PascalCase codes),
app-homing (code ?? reason). API_ERROR_CODES comes from the manifest, and
HOME_TENANT_UNRESOLVED plus the cascade codes are `satisfies ApiErrorCode`.
InstallsTab is the first site showing the reference.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every non-2xx body in the Functions project now goes through ResponseHelper
(ErrorAsync / BadRequestAsync / NotFoundAsync / ForbiddenAsync /
UnauthorizedAsync / ConflictAsync / InternalServerErrorAsync): 408 sites by a
pattern-exact rewrite of the CreateResponse + WriteAsJsonAsync + return shape,
50 by hand. The eight private BadRequestAsync shadow helpers are gone; the raw
500 catch blocks use InternalServerErrorAsync (sanitized message, correlation
id, MCP hint). 429s keep Retry-After via retryAfterSeconds.
Specialised error bodies where a consumer reads more than the prefix:
AppHomingDeniedResponse (kebab reason codes now Constants.AppHomingReasonCodes,
probe result), TenantConfigPatchFailedResponse (backupId, drift),
QueryBackendLogsErrorResponse (upstreamCode, statusCode, source, hint,
upstream). Twenty-one message-first 200 bodies that the old error tolerance had
let through are typed too (MessageResponse, PreviewWhitelistActionResponse,
SuccessMessageResponse). New codes: PayloadTooLarge, UpstreamError,
UpstreamTimeout, the three trial-gate codes; the agent security path
(SecurityValidationExtensions) writes the envelope with the retry window.
TypedResponseGuardTests: WriteBaseline is empty again — the anonymous
WriteAsJsonAsync ratchet now covers error bodies with zero tolerated sites.
Manifest and generated TypeScript regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every error body now leads with { error, code, correlationId }: the generic
ApiErrorResponse (hint?, retryAfterSeconds?, operation?) plus the specialised
McpQuotaExceededResponse (reordered; `message` became `error`),
DelegatedSlotLimitReachedResponse (+correlationId) and the new
SessionDeletionRejectedResponse (replaces the anonymous delete arms). The
correlation id is stamped by the writers — ResponseHelper.ErrorAsync and the
new ApiErrorWriter for the middleware side (auth 401, policy 403/503, rate
limit 429, MCP quota 429, global exception 500) — never by call sites.
exceptionType and rateLimitInfo leave the wire; Constants.ApiErrorCodes is the
code vocabulary and is exported as the manifest section `apiErrorCodes`
(API_ERROR_CODES for web + MCP). CorrelationIdMiddleware names the readable
headers via Access-Control-Expose-Headers.
Guard: TypedResponseGuardTests no longer tolerates anonymous error shapes;
the 458 legacy sites in 149 files are frozen in WriteBaseline to melt down.
New facts: envelope-prefix reflection, ApiErrorWriter body/header pins,
exact-shape pins for the generic envelope and the changed specialised bodies.
Reverses the "error bodies stay anonymous" clause of D-043 (decision entry
follows with the docs stage).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first cut refused every reader-zone-fallback endpoint. The DeviceSetup line is
written before the agent starts and is backlog on every device, so that silenced
ANALYZE-ESP-001 for all agents without an era anchor (control sessions 2708e6b4 and
18d80f9c came back "not measurable" after the deploy). Two fallback endpoints share the
writer-zone error and cancel it; only a span mixing a fallback endpoint with an anchored
one (or with "now" for an open phase) is unmeasurable — the a7140f98 shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hybrid_login_pending now measures the absence of a real-user desktop after a reboot
(cancelled by the DesktopArrivalDetector or a real JoinInfo user), is never armed on
WhiteGlove devices (resume marker, Part-2 flag, .part1- archive) and reports the
JoinInfo placeholder as a fact only — on a Hybrid join the AD sign-in never replaces it.
New EntraUserAffinityDetector: armed at the real-user desktop on Hybrid devices, the
first IME-TOKEN-SUCCESS line after the desktop posts ime_user_token_acquired; ten
minutes without one post entra_user_affinity_pending with the IME token-failure codes.
ImeLogTracker exposes raw per-line token callbacks past the historic-replay gate;
HelloTracker surfaces User Device Registration 304/305 as device_registration_event.
ANALYZE-ID-004 v2.0.0 keys on those events (event_type not_exists gained a branch),
GATHER-DEVICE-006 follows, GATHER-DEVICE-007/008 collect the Entra operational log.
Constants + MCP catalog gain the five types (D-072), the guard test learns the
multi-line OnTraceEvent shape, gather_* outputs pass the MCP filter gate by prefix.
CMTrace: ImeLogEraPreScan/CmTraceEraTable anchor the backlog era that holds the
bootstrap execution record against the Deployed install marker (0.13-0.36 s in the
field), whole era or nothing, sibling files by local time; origin era-anchored.
Backend phase_duration/app_install_duration refuse a reader-zone-fallback endpoint.
Decisions D-190, D-191. Session a7140f98.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A service principal (idtyp=app, typically behind a federated credential) is admitted only
with the access_as_application application permission and then keys every role table,
binding, audit actor and throttle bucket as app:<client-id>. Caps at resolution: Viewer in a
tenant, DelegatedReader on managed tenants, never a platform role; the roleless catalog tiers
are closed to applications except GET auth/mcp. MCP guard keys on the same principal key.
Members / MCP Users / Delegated Access get a User / Service principal switch; usage views
badge App; McpServicePrincipalFirstSeen ops event and PrincipalKind request dimension.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A tenant in the owned group of a permanent-Pro tenant is effectively Pro with source "msp":
badge "Pro (MSP)" (also over its own Pro), the Pro entitlement set without the delegation
right (no transitive delegation, no slots), no trial CTA. Nothing is written for that —
TenantConfiguration.ManagedByProTenantId is a load-time projection from a process-wide
index over the Tenant Groups table (owner tier read raw, never through the config service),
so the end of the delegation, the manager losing its permanent tier, or its offboarding
revert automatically. The one write is the retention grace anchor (ProDowngradedUtc),
stamped by ProConferralService from the four paths that end a conferring relationship.
- FeatureEntitlementCatalog.Resolve → EditionResolution { Edition, Source, OwnPro };
Get(resolution) returns ProViaMsp for conferred Pro; the 3-argument ResolveEdition is
gone so every caller resolves from the config (incl. the projection)
- DelegatedAdminService home gate checks the DelegatedAdminAllowed entitlement, not the edition
- TrialExpirySweep reads through the config service: a managed tenant's expired trial emits nothing
- feature-flags + plan response carry editionSource; config/all projects payingCustomer and
managedByProTenantId; shared manifest + web/MCP codegen regenerated
- PayingCustomer: GA-only sales bookkeeping flag on the plan endpoint, no entitlement effect
- web: "Pro (MSP)" label/tooltips, plan section badge, delegated-access hints, tenant
management chips + paying checkbox
Tests: catalog matrix, ManagedTenantProIndexTests (incl. mutual management without recursion,
fail-closed on storage error), ProConferralServiceTests, projection on every config read path,
feature-flags payload, parity facts, serialization (projection never stored), plan transitions,
edition gate, sweep. Backend 5902 green, web tsc/vitest/lint clean, MCP tsc/vitest green.
Concept doc + D-188 in internal (gitlink); customer docs in the docs repo (MCP redeploy needed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Click a bucket to narrow the map to it, add more with further clicks, Show all
or the last toggle returns to the full map; a Color-by switch resets it. Camera
fit and marker radius scale stay on the full location list so filtering never
moves or rescales the map. Customer docs updated in the docs repo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL flagged the new denial flow (cs/log-forging #83-#87): header values and the request
path travel from the deny branch into a log line, the ops-events table and the alert
channels. Headers were already stripped of control characters and capped; the path was not,
and Request.Path is percent-DECODED, so %0A arrives as a newline. BuildPrivilegedDenial now
cleans method and path (LogSanitizer) and caps the path at 256 chars, once, before any sink.
Test pins CR/LF stripping and the cap. The remaining alerts are the known false-positive
class (structured ILogger sinks with sanitized discrete properties).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live check after the x29 deploy: a KQL syntax error came back as 502, and the MCP sanitizes
every 5xx down to the error code — the syntax message and the forwarded upstream JSON were
invisible, exactly the parity loss the proxy exists to avoid. Upstream 4xx (other than
401/403) now maps to 400 with message, hint and `upstream`; 401/403 (the managed identity's
missing read grant, with a hint naming the role) and store 5xx stay 502. The MCP's structured
4xx renderer appends `upstream` as a fenced block (capped), and keeps sanitizing 5xx.
Tests: status mapping theory, hint texts, upstream rendering on 400 and non-rendering on 502.
Backend 5849, MCP 693 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 9eb8cac5 filters covered gitlink bumps and prose but not the
workflow files themselves, so every edit to build-agent.yml or a deploy
workflow still cost a full CI and CodeQL run. Neither reads any other
workflow: CI runs the suites and drift guards, CodeQL analyzes C# and
TypeScript. paths-ignore cannot negate, so each file lists the other
twelve workflows explicitly and leaves itself out; .github/scripts and,
for CI, .github/codeql join the list for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
build-agent.yml gains an optional release_notes input; the release body opens with
"What's new" from that input, or a generic fallback linking to the docs changelog
plus a marker the operator tooling uses to anchor the next notes, so releases
dispatched by hand leave no gap. --generate-notes dropped (pull requests only);
a compare link to the previous tag replaces it. Plan-first rule: plans live in
internal/work/plans from the first draft. Gitlinks: internal, .claude/commands.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Any signed-in user holds a token the GA-only routes refuse with 403, so the assume-breach
alarm was a push-flood vector: one event per caller AND route per hour, multiplied by
Flex instances. PrivilegedDenialReporter now keys the throttle on the caller alone (a sweep
over the whole GA surface is one event; every attempt still lands in the Warning trace) and
carries a fixed per-instance window budget: 5 events per hour, then ONE Critical storm
marker (same event type, so the same Telegram rule pushes it) and trace-only silence until
the window resets. Worst case per instance and hour: 6 pushes. Global Reader slips count
against the budget too. Tests pin the per-caller throttle, the storm marker and the Reader
accounting; D-185 amended.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One call replaces the six fetches of the backend-version skill: backend /api/health vs
versions/backend.json, portal /version.json vs versions/web.json, this server's own build
(build-info.ts, now shared with /health and the boot line) vs versions/mcp.json, and the
agent release manifest. Per component: live, manifest, drift (in-sync | drifted | unknown |
not-applicable) with a note that names the missing half — a manifest 404 is "never deployed
since manifests exist", never a silent in-sync; failed fetches land in partialErrors
(allSettled). The well-known hosts join config.ts as the URL guard requires. Platform scope
(GA + Global Reader); catalog snapshot updated. The local git comparison stays in the skill.
Closes the build of backlog x29 (all four phases); deploy + live verification tracked as
backlog `sch`, the user-side RBAC / app settings / alert rule as `ket`.
Tests: deployment-state (drift arithmetic, fan-out, 404, unreachable), MCP 691 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report ZIP a tenant admin submitted and the preserved diagnostics copy could only be
fetched with a storage key through an az script. They now follow the diagnostics ticket
model: mint under the caller's JWT, download with a short-lived HMAC ticket, bytes proxied
through the backend — a storage SAS never leaves it.
Backend
- DiagnosticsDownloadTicket gains a purpose parameter (default diag-dl-v1, new
report-dl-v1): a ticket for one download surface is rejected on the other (wrong_purpose).
- POST global/session-reports/download-ticket (GlobalReadOrAdmin, same tier as the portal's
download-url): flat-name guard, existence check, best-effort size, ticket bound to the
caller's home tenant; reuses DiagnosticsDownloadTicketResponse (destination SessionReports).
- GET global/session-reports/download (PublicAnonymous, ticket is the sole authority):
per-IP limit + decode via the new TicketDownloadPrelude (shared with the diagnostics route),
streamed from the session-reports container with BlobStorageService.OpenReadAsync.
- Constants.BlobContainers.SessionReports replaces three duplicated literals.
- Pins: catalog completeness/JWT-exempt theories, auth-skip, no-store, purpose tests.
MCP
- get_session_report_download (platform scope) mirrors get_session_diagnostics: kind
(session-report / preserved-diagnostics / diag-files-report), size, download URL, expiry,
instructions; catalog snapshot updated.
Submodules: session-report-debug skill on the tool, fetch-report-zip.sh deleted; plan/todo.
Tests: backend 5839, MCP 683, solution build green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
POST /api/global/raw/logs (MCP query_backend_logs) now takes `source` = backend | web | mcp
and `budgetSeconds` 5..180 (default 30). Parity with the az CLI was already by construction
(same Kusto REST endpoint, verbatim query); the four remaining deltas were properties of the
proxy and are gone (D-186):
Backend
- LogQuerySources (Shared vocabulary, exported as manifest section `logSources`) and
LogQuerySourceCatalog: APPINSIGHTS_APP_ID → backend, APPINSIGHTS_WEB_APP_ID → web (both
api.applicationinsights.io), MCP_LOG_ANALYTICS_WORKSPACE_ID → mcp (api.loganalytics.io).
A missing setting makes one source answer 503 naming it, never the others.
- AppInsightsQueryFunction: injectable IHttpClientFactory (named client without timeout) and
TokenCredential; one CancellationTokenSource per request plus `Prefer: wait=<budget>`
upstream, 504 names the budget; typed QueryBackendLogsResponse keeps the Kusto
tables[].columns/rows verbatim (JsonElement cells) and adds source, budgetSeconds,
elapsedMs and `partial`/`partialReason` when the store cut the result (previously
swallowed); on failure the full upstream error JSON rides along as `upstream`.
- TypedResponseGuard: the last SerializeBaseline entry is gone (typed request record).
MCP
- query_backend_logs: source (z.enum from the generated LOG_SOURCES), budgetSeconds, a client
signal of budget + 15 s, rawTable result cap, description with per-source table hints.
Submodules
- .claude/commands: backend-logs / ux-insights / mcp-telemetry on `source`; query-appinsights.sh
deleted (35 call sites rewritten); az only for the live container tail.
- infra: migration scripts carry the two new app settings and role assignments
(Monitoring Reader on the portal component, Log Analytics Reader on the MCP workspace).
- internal: concept doc backend/log-query-proxy.md, D-186, docs and plan updated.
Tests: catalog resolution, budget clamp, Kusto parsing incl. PartialError, error extraction,
wire parity of the new response; backend 5832, MCP 683, web tsc, solution build green.
Before live use: grant the two roles and set the two app settings (see the plan file).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reverts the HasAccountSetupActivity tightening from f0a3f043: measured on 485 Classic
(SkipUser=false) sessions, 96 see a single Shell-Core 62407 before any AccountSetup
progress and 75 of them complete AccountSetup only through the user-apps-settled synthesis,
which needs that exit remembered as the edge. Windows confirmed categorySucceeded in 0/485,
so the synthesis is the load-bearing completion path; presence semantics stay and are
documented as such.
UserEspKeepAwakeHost instead gets an additive progress probe
(ProvisioningStatusTracker.HasAccountSetupProgress via EspAndHelloHost) and releases on an
EspExiting that arrives after the AccountSetup registry left notStarted — the real user-ESP
page exit (progress precedes it by >= 1 min fleet-wide; the Device->Account handoff exit
always precedes progress). AccountSetupProvisioningComplete no longer releases; desktop
arrival is deliberately not a trigger (explorer.exe ran before the final exit in all 385
two-exit sessions). Sessions with only the handoff exit hold until the safety cap. Release
reason user_esp_exited replaces account_setup_complete. D-183 rewritten.
Tests: host (release on exit+progress, handoff exit holds, completion no longer releases,
exit before engage ignored), tracker (presence stays true / progress false on the pre-written
all-notStarted JSON). Solution build 0 warnings; agent suite 2823 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 2026-09-04 audit found no bypass of the GA boundary, but a failed attempt left
only a Warning trace without identity, and a non-GA tools/call for a GA-only MCP tool
never reached the backend at all. This makes the boundary observable without adding
a second alarm writer (D-185):
Backend
- PrivilegedRouteDenied (Security): raised from the policy middleware's deny path for
every 403 by an authenticated caller on a GlobalAdminOnly route. Critical for a
caller without a platform role, Warning for a Global Reader. Details carry upn, oid,
tid, client source, MCP tool name, correlation id, reason and policy. Throttled one
hour per caller+path per instance (PrivilegedDenialReporter, IPrivilegedDenialReporter
seam). PolicyResult now surfaces the denied tier; the DENIED log line gains oid, tid,
clientSource and mcpTool.
- GET global/raw/access-probe: no-op GlobalAdminOnly route whose denial IS the signal
(AccessProbeResponse DTO, catalog entry, parity test).
- Defense in depth: AppInsightsQueryFunction and both TableQueryFunction triggers re-check
IsGlobalAdmin in the body (RawGlobalAdminGate); an empty RequestContext fails closed.
- Dual-register: OpsEventTypes.All, OpsAlertRulesSection.tsx, regenerated manifests.
MCP
- ga-tool-probe.ts: before dispatch, a caller without the GlobalAdmin role naming a
strictGa tool leaves an unconditional [mcp-security] line (tool, upn, scope) and fires
the access probe with X-MCP-Tool-Name; the SDK answer stays "Tool not found".
- GA_STRICT_TOOL_NAMES exported and pinned equal to the registered GA-minus-Reader set.
- debug-session prompt names query_backend_logs/query_table only for strictGa (Readers
were told to call tools they cannot see); tool_call_rejected lines carry scope + upn;
config-tool comment corrected against the actual catalog tiers.
Tests: reporter (severity, details, throttle, fail-soft), middleware policy surfacing and
IsPrivilegedDenial/BuildPrivilegedDenial, Program.cs middleware order source scan, raw
gate 403, probe catalog + JWT pins, wire parity; MCP probe unit tests, Global Reader over
HTTP on both eras, runtime refusal fires the probe, prompt gate.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent: ProvisioningStatusTracker.HasAccountSetupActivity now requires at least one
AccountSetup subcategory past notStarted. Windows writes AccountSetupCategory.Status with
every subcategory notStarted BEFORE the Device-ESP page exits, so the presence check
confirmed the Device->Account handoff (Shell-Core 62407) as the final ESP exit; the
user-apps-settled synthesis then completed AccountSetup and released the User-ESP
keep-awake minutes before the real exit (fleet: 77 % of keep-awake sessions, median 3 min;
on battery that is enough for Modern Standby). D-183.
Rule engine: filterField/filterOperator/filterValue now apply to event_data and
event_data_array as a same-event filter (shared ApplyValueFilter with event_count).
ANALYZE-DEV-012 v1.1.0 excludes backfilled pre-session episodes (backfilled equals false);
a second independent condition could not, since each condition scans all events on its
own. D-184. Schema, Shared model, MCP authoring guide and generated types follow.
Tests: ProvisioningStatusTrackerAccountSetupActivityTests (the verbatim all-notStarted
registry JSON), SleepEpisodeRuleTests (+3 backfill cases), RuleEngineEventDataFilterTests.
Solution build 0 warnings; agent suite 2820 and backend suite 5791 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every "chore: bump internal gitlink" commit ran the full CI matrix and
both CodeQL analyses, because a gitlink bump reports the bare path
'internal' (no trailing slash) and the existing 'internal/**' filter
does not match it. Both workflows now ignore the bare and nested forms
of the three private submodule paths plus markdown, LICENSE and the
.github metadata files. CodeQL's weekly unfiltered schedule remains
the backstop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- .gitmodules: docs -> internal, repo renamed to Autopilot-Monitor-internal; the OKF bundle now lives at internal/docs/
- .claude/CLAUDE.md rewritten: where knowledge lives, working-file rules, hard rules; imports the private half from internal/CLAUDE.md (silently absent without submodule access)
- .claude/architecture-principles.md moved into the internal bundle (reconciled against the code)
- tasks/ (gitignored) retired; working files live in internal/work/
- doc path references docs/<bundle> -> internal/docs/<bundle> in code comments, skills gitlink, codeql paths, build script
- rule descriptions no longer cite an internal doc path (customer-visible text); rules/dist regenerated
- public tree no longer names private operator tooling
- .claude/commands gitlink: skill docs point at the new paths
A domain typed into the cross-tenant filter only became a tenant ID when a suggestion was
picked; submitted raw it was dropped by the fetch (not a GUID) while the client scope compared
it against session tenant IDs, leaving zero rows and "No sessions found for this tenant yet".
Submit now resolves GUID / exact domain / unique substring, updates the box to the ID, fetches
with the resolved value, and the empty state says "No tenant matches" for unresolvable input.
The search box never matched the tenant column: tenant ID and domain are now part of the
searchable text and the suggestion dropdown (cross-tenant only), sourced from one
tenantId-to-domain map shared by table and filter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two tenants clicked the migration banner, consented the new app, and were refused by the
role-superset probe without ever learning why: the previous app's service principal holds a
tenant-side add-on grant (DeviceManagementScripts.Read.All) that no re-consent can produce, and
the grant command on the Optional Graph capabilities page pre-fills the homed (previous) app.
Backend: the probe now separates manifest roles (still propagating after consent ⇒ transient,
the retry converges) from add-on roles (permanent ⇒ MissingRoles names them). consent-status and
access-check carry appHomingPending + appHomingMissingRoles; the manual flip's probe payload
carries missingRoles. The access-check invalidates the Graph caches BEFORE the homing probe so
"Detect existing access" judges a fresh primary token after the admin's grant.
Web: TenantConfigContext learns the blocking roles from either response and the post-consent
poll keeps going while the switch is still converging (previously it stopped on the first
legacy-side reconcile and left the tenant un-switched). Settings → Autopilot Validation shows a
"One more step" banner with the roles, the grant command pre-filled with the NEW app's client id
and -Permissions, and the re-check button; the admin flip error names the roles too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent's 48h emergency break is wall-clock, so devices powered on after a shelf
period fire it days after the sweep already terminalized the session (2026-09-03
audit: 224 of 269 breaks in three days were such late cleanups from one kiosk tenant,
all Warning, burying the real ones).
- ReportAgentErrorFunction.ClassifyEmergencyBreak: session already terminal
(Succeeded/Failed/Incomplete) => Info + lateCleanup=true; open, unknown or
unreadable => Warning (historical semantics). Row read is best-effort.
- OpsEventService.RecordAgentEmergencyBreakAsync takes the severity and stamps
sessionStatusAtBreak/lateCleanup into message and details.
- Timeline event agent_emergency_break stays Warning (classifier rule 3 keys on type).
- SessionOwnerBindingObserver: document that the ops-event throttle is per Function
instance (IMemoryCache) and deliberately so.
- Tests for both branches, the fail-safe path and the pure rule; docs submodule bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Performance Map gets a "Color by" selector (enrollment duration, success rate,
API latency, DO peer efficiency, App-Load-Score) and a legend under the map. One
bucket definition per mode (mapColorModes.ts) feeds both the Leaflet markers and
the Location Performance table badges, so map, legend and table share thresholds.
The map's duration scale moves to the table's 0.8/1.0/1.2/1.5 ladder.
Also: page.tsx uses the generated wire types instead of drifted local interfaces,
formatThroughput comes from lib/formatting, marker tooltips show latency and DO share.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The four README screenshots were still from the private preview (old blue
logo, preview banner). Retaken in the light theme with the current logo,
tenant view only, no banner. Fleet Health uses the 90-day window; the geo
map has too few locations in the own tenant to read as a world map.
Also stores the GitHub social preview as source (HTML composition plus the
anonymized dashboard capture it embeds) and the rendered 1280x640 PNG.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Root LICENSE is now the verbatim AGPL-3.0 text so GitHub detects the license
instead of showing "Other"; the split model (MIT for Agent + Shared) is
explained in the README and each component keeps its own license file.
- README: MCP server in the component list, an "AI Integration (MCP)" section
with example prompts and the server URL, and a neutral Feedback section.
- SECURITY.md routes reports to private GitHub Security Advisories.
- CONTRIBUTING.md is a feedback routing page (issues, discussions, security,
Report Session button), CODE_OF_CONDUCT.md is short and neutral.
- Issue forms for bug reports and feature requests; blank issues disabled.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- /about: "Common Questions" section (15 user problems → feature answers) from one source array, mirrored as FAQPage JSON-LD
- root layout: SoftwareApplication JSON-LD becomes an @graph with Person (author/publisher), Organization glueckkanja AG (provider), WebSite, featureList, softwareHelp
- public/llms.txt with portal, docs, and source links
- robots: explicit allow block for GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User, Google-Extended
- drop the unverified "policy conflicts" claim from the analyze-rules feature card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- TenantConfiguration.McpUsagePlanOverride: a SectionUsagePlans plan name pinned to a whole
tenant (members' default user plan + organization windows); precedence per-user override >
tenant override > edition default; never changes the edition
- PATCH config/{tenantId}/plan accepts mcpUsagePlan (validated against the definitions,
400 on unknown names; null/blank clears), audited, echoed as mcpUsagePlan/mcpUsagePlanOverride
- Tenant Management: "MCP usage plan (override)" select fed by global/config/plan-tiers
- Quota 429: every Community block now says Community is sized for occasional use and Pro
lifts the window; managed-tenant block names whose plan governs it
- Field in BaseDeniedFields/RevertProtectedFields/projection, Store/Map, manifests regenerated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase A — the budget follows the data
- A delegated (MSP) read is charged to the MANAGED tenant's organization MCP budget and checked against
its plan; the caller's own budget still follows the home tenant. 429 names the managed tenant (domain)
and the Pro upgrade path; new McpQuotaExceededResponse.targetTenantId.
- Bounded fleet aggregate (global/sessions, global/stats/sessions): exhausted managed tenants are excluded
softly — RequestContext.AllowedTenantIds is narrowed (never widened, never null; pinned) and echoed as
quotaExcludedTenants. Global Admins stay exempt and are tracked on their home tenant only.
- Tenant Group flag ChargeHomeTenantQuota (PATCH global/tenant-groups/{id}, audited per tenant): reads
through the group are charged to the assignee's home tenant (operator-managed service groups).
- McpTenantUsage rows carry UserPrincipalName / HomeTenantId / LastRequestAt; new
GET metrics/mcp-usage/organization (budget by account incl. delegated readers) rendered on MCP Usage.
- MCP: get_fleet_overview (delegated + platform) over the two subset-tier routes; error handler names
the managed tenant. Other delegated tools stay tenant-mandatory.
Phase B — delegated tenant slots
- EditionEntitlements.MaxDelegatedTenants (Community 0 / Pro 2), TenantConfiguration.MaxDelegatedTenantsOverride
(GA-only via PATCH config/{tenantId}/plan { maxDelegatedTenants }), DelegatedSlotService counting distinct
managed tenants per home tenant through identity bindings (+ pending invitations + 24 h holds), enforced
fresh on grant / group assign / add-tenant with 409 DelegatedSlotLimitReached; GA UI offers
"raise to N & retry"; GET global/delegated-slots/{tenantId}; feature-flags entitlements.maxDelegatedTenants.
Phase C — self-service delegation
- One implicit Tenant Group msp-{tenantId} per managing tenant (OwnerTenantId on the meta row);
DelegationInvitations table (invitations + slot release holds); DelegationInviteTicket (HMAC, 7 days,
locates the row only — the accepting tenant is always the JWT tenant; one-shot via ETag).
- DelegationSelfService: ordered accept chain (distinct codes), remove/revoke with 24-hour slot hold, dual
audit and live-stream disconnect, assignees bound to tenant membership, "who manages my tenant" view;
GA release-hold escape hatch. No delegations/* route carries {tenantId} (pinned).
- Web: Settings → Tenant → Delegated Access, /delegations/accept?token=, AuthGate keeps the deep link
across the activation detour, Tenant Management holds + release, owned groups read-only in GA UI.
- Offboarding wipes both sides (documented two-bucket membership); 90-day sweep of terminal rows.
- Trust pages (terms, privacy) and generated wire types updated; docs submodule bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL flagged the re-save in AuthGate (js/clear-text-storage-of-sensitive-data,
alert #82): the value flowed back into sessionStorage from the consumed
return URL. The link only has to survive one more sign-in round trip, so the
re-home path now reads it without clearing and never writes it again.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Session details, diagnosis, annotations, fleet health, geographic performance,
SLA, software, usage metrics, audit log, system health, IME log patterns and the
rules pages link to their guide from the end of the header control group.
Navbar help links use the DOCS_PATHS catalog (service announcements no longer
goes through a redirect).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Backend: the app-homing consent probe now requires the primary app's Graph
roles to be a superset of the legacy app's, so a mere sign-in consent
(User.Read, no application role) can never auto-flip a tenant onto an app
without the device-validation permission.
- Web: dashboard banner for tenants still on the previous app (per-tab
dismiss, docs link); docs links on the settings funnel banner, the flipped
banner and the failed-sign-in screen; immediate re-homing after a redirect
sign-in on the other app (one hop per tab, never after a cross-app
fallback); the www -> portal handover passes the learned app as ?authapp=.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dashboard selection could grow across pages without limit and a bulk delete was one click on the red button. The effective selection is now cut to BULK_MAX_TARGETS (100, the largest page size) and every pick path (row, Shift range, page checkbox) fills only up to the remaining room. The delete dialog lists every target and, above BULK_TYPED_CONFIRM_THRESHOLD (10), asks for the word DELETE before the button arms. Block stays one click because it expires after 24 hours.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CompanyName joins ContactEmail as the tenant contact profile: optional on Community,
both required by the self-service trial start (one 409 ContactProfileRequired verdict
naming every missing part). GA plan assignment stays soft (admin warning). Feature
flags expose companyNameSet as a boolean only; TenantTrialStarted carries companyName.
Web: Contact section field, Plan gate and dashboard banner name what is missing,
admin panel/report/search know the company. Privacy page lists the new datum.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The organization-quota card called toLocaleString on undefined when the
backend predates the tenant quota (deploy window, local dev against prod),
tripping the error boundary. Tenant fields are optional on the client now;
the card renders only when they are present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- SectionCardHeader replaces 33 hand-copied card headers across settings, rules and admin pages
- DocsLink + lib/docsPaths: every tenant-facing section links its documentation page (new tab)
- docsPaths guard test encodes the GitBook URL rule (group slug, not folder name)
- delete dead MaintenanceSection duplicate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every X-Client-Source: mcp request is now charged against two budgets: the
caller's own daily/monthly windows (per-user override or tenant edition plan)
and the organization-wide daily/monthly windows of the token's tenant, which
always follow the tenant's edition plan so a per-user override never widens
them. Ten accounts no longer mean ten times the budget.
Tenant counters live in the new McpTenantUsage table (PK = tenantId,
RK = {yyyyMMdd}_{oid}: one partition range per check, no hot row, never a
property scan over UserUsageLog); exact-PK offboarding bucket and 90-day
retention included. PlanTierDefinition gains nullable tenant limits (null =
catalog fallback: Community 300/9000, Pro 3000/60000; 0 = unlimited). The 429
body carries level=user|tenant and a message naming whose budget it is,
X-MCP-Quota-Tenant-* headers ride along, and mcp-usage/me exposes both
budgets (wire types regenerated).
MCP server: the quota 429 body was rendered as Error: undefined because it
has no error key; it now gets a dedicated block with the reset time and a
level-specific suggestion. Web: Usage Plans edits the tenant limits; the
self-service MCP Usage page renders both budgets (the quota node was never
displayed before).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
McpAccessPolicy.AllMembers returned Allowed for any valid JWT, so a Progress
Portal end-user without a role could open an MCP session. The AllMembers path
now requires an effective role (Admin / Operator / Viewer) in the token's
tenant, resolved by the new TenantMemberRoleResolver that the policy
middleware shares (table first, Entra app-role claim when the tenant opted
in). Explicit grants (platform role, delegated scope, enabled McpUsers row)
and the Disabled-row kill-switch keep working in a policy-independent order;
auth/mcp and auth/me hand the token's app roles through.
The MCP Users settings section shows the user list under AllMembers too, as
the per-user override list (individual usage plan, disable = block).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fixture waited 5 s per posted signal and the scenarios 10 s for a
terminal stage. Those are deadlock guards, not performance assertions:
every applied step performs 5-6 WriteThrough + Flush(true) writes and 2-3
atomic renames synchronously on the ingress worker, and on a cold GitHub
runner volume the first scenarios after the parallel phase still blew the
5 s budget even though the collection is already serialised (run
33581374985: the first four scenarios in run order failed, the other nine
passed; the rerun of the same commit was green). One constant,
EnrollmentOrchestratorFixture.SettleTimeoutMs = 30 s, now covers the
per-signal, terminal-stage and uploader waits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`dotnet test --verbosity quiet` also sets the console logger to quiet, which
drops the "Error Message" block of a failed test — run 33581374985 showed
only four "[FAIL]" lines for the V2 Core scenario flake and nothing about
which fixture budget blew. An explicit `--logger "console;verbosity=minimal"`
keeps the build quiet but prints the failed tests with message and stack.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server already strips SignalR Quota, Poison Queues and every endpoint URL
for non-Global-Admins, but the page mirrored the Global-Admin VIEW only for the
build blocks, so demo mode still showed the full operator report. The new
lib/healthCheckView.ts applies the same rule presentation-side (cards + URL
detail rows), and both card render paths share one HealthDetails component
that puts URL values on their own line under the label instead of wrapping
them right-aligned next to it.
The healthy agent-binaries message no longer advertises the legacy blob; that
account stays in the Global-Admin-only details.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The per-client opt-in stays available on the server; this repo's .mcp.json no longer sets it,
so operator sessions run compact too and pay no indentation tokens. Set the header in your own
client config when you need to read raw results.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Compact JSON stays the platform default: measured with an OpenAI-family tokenizer on realistic
payloads, 2-space indentation costs 14-46 % more tokens (2-2.7 tokens per line for newline,
indent and colon spacing) — gzip removes it from the wire, not from a model's context. A human
reading results in an interactive client opts in per client with `X-MCP-Pretty: 1`; the access
guard stores the preference on the caller context and stringifyResult honours it. This repo's
.mcp.json sets the header so operator sessions in Claude Code get readable output. The
MCP_PRETTY_JSON environment switch remains the global escape hatch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- get_session_diagnostics no longer embeds the ~5k-character ZIP layout per ticket; it points at
the static diag_zip_layout resource (read once per conversation). Prompt text updated.
- get_resource accepts `section` to read one top-level part of the rule-authoring trio; the
section list in the description is derived from the live catalogs. RESOURCE_NAMES now lives in
resource-catalog.ts as the single source of the name enum and the ResourceName type (the inline
z.enum baseline entry is retired with it).
- search_knowledge no longer repeats type/title inside metadata.
- search_events walks up to three candidate event types concurrently (TYPE_WALK_CONCURRENCY);
pages within a type stay sequential, results stay in candidate order, budget/truncation
semantics unchanged.
Deliberately not done (operator decision): a longer undici keep-alive (runtime dependency for a
single-digit-ms in-region gain; the backend sends no Keep-Alive hint) and precomputed tool JSON
schemas (7-12 ms per request on a dev CPU, would need SDK-registration patching).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
30-day tool telemetry: callers pass fields explicitly in more than 90 % of get_session_events /
query_raw_events calls, and reads filtered by eventType keep the payload about half the time
(the ime-decompile skill reads data.msiDownloadUrl after an eventType filter without fields).
So the lean default (no data / no DataJson) now applies only to unfiltered timeline reads and
announces itself in-band (omittedFields + omittedNote); a read filtered by
eventType/severity/source returns full events as before. Never worse than the former default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Backend
- SingleFlightCache<T>: per instance, TTL, single-flight, failures never cached, expiry sweep, optional single slot
- Rule catalog caches in TableStorageService.Rules.cs: gather rules / rule states / IME patterns per partition (60 s), every Store/Delete invalidates, hand-outs are clones (GatherRule/ImeLogPattern/RuleState.Clone()); GatherRuleService and ImeLogPatternService seed behind a gate and read global/tenant concurrently; GetAgentConfig reads rules and patterns concurrently
- Tenant-id snapshot (60 s) shared by every cross-tenant fan-out; the paged session list no longer rescans TenantConfiguration per page
- BoundedFanOut + DrainSessionsIndexWindowAsync: one PartitionKey-anchored, RowKey-bounded, projected query per tenant; GetAllSessionStatsAsync drains with SessionStatsProjection under a 30 s single-flight cache; GetGeoWindowSessionsAsync reads the index key range; GetSessionsByDateRangeAsync fans out per tenant
- App summaries: cross-tenant windowed projected reads fan out per tenant over a union projection and share a single-slot 60 s snapshot (Installs tab pair, app detail, usage refs); days clamp on global/metrics/app
- CancellationToken on the uncached geo/date-range reads (IMaintenanceRepository)
- EventFieldProjection accepts data.<key> slices
- BackendStartupMs measured from Main (StartupClock) instead of Process.StartTime, which carried the Flex placeholder lifetime
Web
- Dashboard stats: 30 s SignalR debounce in the GA aggregate scope (every tenant's events drove a cross-tenant drain every 3 s)
MCP
- Compact JSON tool results and resources (MCP_PRETTY_JSON=true opts back in)
- DEFAULT_FIRST_PAGE_SIZE 50 for every paginated tool; the auto-exhaust budget is row-based (2000 rows) so scan coverage does not shrink
- Lean first-page projections: get_session_events without data, query_raw_events without DataJson; announced in-band (omittedFields/omittedNote) and restorable via fields; a continuation keeps the nextLink projection
- get_session_summary fetches the timeline with the triage fields plus data.<key> slices for its guards
Docs: docs/backend/read-caches-and-cross-tenant-fanout.md (submodule 11fc393).
Deploy order: backend, web, then MCP (the summary's data.<key> projection needs the new backend).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opt-in Select button (Admin Mode) adds a checkbox column, row-click
toggles selection, Shift+click ranges, header checkbox selects the page,
Esc/Done leaves. Bulk actions loop over the per-session endpoints with
three requests in flight and one summary toast; no bulk endpoint.
Confirm modals list the targets. Also translates the block tooltip and
the block error alerts to English.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 2026-08-23 instrumentation cutover re-fired as per-path share regressions
(sweep:r6 lift 8x, sweep:r5_incomplete 5x) because the single stamped baseline
day already cleared the 5-hit floor while the legacy:* history was not counted.
Rule-shaped legacy rows now fold into the per-path BASELINE of sweep:{rule}
(baseline only - derived rows never become window evidence). Failed rows whose
reason is the agent's esp_failure message derive as agent:esp_failure_fallback,
matching the stamped ingest path, which removes the fake agent:failed drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The V2 Core integration scenarios drive the real orchestrator against
wall-clock budgets (5 s per signal, 10 s for Completed / uploader). In
isolation every scenario finishes in under a second; inside the fully
parallel suite on a 4-core runner the same scenarios took 4-7 s locally
and blew the budgets on two slow GitHub runners (CI runs 33564658714 and
33565773888). Serialising them after the parallel phase, like
EnrollmentOrchestratorTests and RecoveryPathTests already are, brings
them back under 700 ms without changing the budgets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A plain wrapper around dotnet restore/build that nothing referenced. Its
"next steps" advertised a local self-hosting workflow (Azurite, func start)
the project does not support, and its name collided with the real agent
release script scripts/Deployment/build.ps1 that the docs and workflows
mean by "build.ps1". Building is `dotnet build AutopilotMonitor.sln`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test code used the real test-tenant GUID, a real UPN, a real VM serial (and
its Intune-normalized form) and a real hostname as literals. None of the
assertions depended on the specific values; they are contoso/1111…/1234… now.
The one that stays is CertTenantBindingTests: its tenant GUID is stamped
inside device-cert-sample.pem and can only change together with the fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two Bicep templates — and the deployment scripts still to come — do not
belong in a public repository. They now live in okieselbach/Autopilot-Monitor-infra
(full history via git-filter-repo) and are mounted back at infra/, so the code
comments pointing at infra/mcp-server.bicep stay valid. No workflow reads
infra/, so CI is unaffected. The *.parameters.json ignore rule moved into the
submodule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screenshots and the logo lived in docs/images, which is now a private
submodule and cannot render on the public README. They are README assets
only (no doc page references them), so they move to .github/assets — the
usual home for repository-level images — and leave the docs bundle.
The architecture link pointed into the private docs; the technical
documentation is not part of the public surface any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository is public; the technical docs carried security-mechanism
gaps, incident details and operator process. They now live in the private
repository okieselbach/Autopilot-Monitor-docs-internal (full history,
extracted with git-filter-repo) and are mounted back at docs/, so every
code, skill and memory path reference stays valid.
log.md is gone: it duplicated the commit messages. index.md remains the map.
CLAUDE.md: concept docs only for invariants and decisions a later change
could silently break; commit inside the submodule first, then bump here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four Graph-backed validators ran on the unnamed HttpClient (100 s default timeout), two attempts each, sequentially, with an uncancellable token retry chain underneath: GetAgentConfig tails of 30-102 s for an agent that gives up after 30 s. DeviceValidationBudget gives the chain one 20 s token and each attempt a linked 8 s token that also bounds token acquisition; exhaustion is a transient result (503 + Retry-After) and the second attempt is skipped once the chain token is spent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The lookup scanned only the newest 100 SessionsIndex rows, so any enrollment older than that horizon answered 'Device Not Found' and a member substring search could land on a newer device. Exact serial and device name now run as server-side eq filters over the tenant partition (stored form or upper-case), newest session wins, then a point-read; the substring page remains the members/GA-only fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CWE-807: the kill switch was keyed only by the caller-declared X-Device-SerialNumber, so omitting or forging the header dodged block and kill. A block now mirrors onto the device's certificate identities (Sessions.OwnerDeviceId) as id:{guid} alias rows in BlockedDevices, resolved at block time; KillSwitchEvaluator checks serial, then identity, then version and stamps the DeviceIdentityBinding request dimension. Alias rows are hidden from listings and exempt from the legacy-key migration; unblock removes them. Session-scoped watchdog blocks were unreachable (no session id ever reached the evaluator): ingest now carries the verdict past the body parse and re-evaluates with the session id, and holds the registered serial against the block list when the header differs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Records the import-session skill: the 23-step cascade inverse, the single
replacement table over keys and strings, what never travels (Owner*,
deletion/diagnostics columns), the SoftwareInventory counter semantics,
scrubbing rules, provenance lane, and the safety model (insert-only under
the new id, key invariant, zero-leak assertion, automatic rollback).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator still listed the removed docs sections, roadmap and changelog
(all moved to docs.autopilotmonitor.com / retired), and lacked /get-started,
which the sitemap emits -- so that entry got "now" as lastmod on every build.
- PAGE_MAP now matches exactly the URLs app/sitemap.ts emits
- generator fails on a missing source file instead of using the deleted
file's last commit date
- sitemap throws at build time for a URL without a lastmod entry
- fix stale comment: the /docs redirects live in staticwebapp.config.json
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both annotation list routes (sessions/annotations/list, global/session-
annotations) accept ?q= -- a case-insensitive substring over the note and
the verdict, deliberately not the lane, author or rule ids, which have their
own filters. Table Storage has no contains, so it is applied in the same
client-of-Azure back-fill loop as ruleId (filtered-out rows never consume
page budget) and bound into the continuation fingerprint, so a cursor issued
for one search cannot be replayed against another; nextLink echoes it.
Portal: a debounced search box on the Annotations page (the setState lives
in the timer callback, not the effect body). MCP: list_session_annotations
gains `query` -> q. Response DTO unchanged, no wire break.
Why: demo sessions imported into the operator's own tenant fall out of every
dashboard/geo/SLA time window. A note ("wifi switch") plus this search is
how they are found again -- and it is generally useful for any judged session.
Deploy order: backend first, then web and MCP (the parameter is silently
ignored by an older backend).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
query_raw_events timed out twice on the same continuation and lost all work each time. Per page the
cross-session path resolved every session's tenant through a SessionsIndex full-table scan (the
EventTypeIndex row already carries TenantId), ran up to 1000 per-session queries serially, applied the
date window last and against the Azure system Timestamp, and had no deadline; the retry was identical
because the nextLink freezes pageSize and the explicit value was dropped.
Backend
- RawEventsScan: index walked in chunks of 100, 20 parallel session fetches, 15 s budget checked
between chunks; cursor always after the last fully processed chunk; QueryRawEventsResponse.Partial
marks a budget-ended page (nothing missing up to nextLink).
- Date window pushed down: Events RowKey range inside the session partition
(BusinessTimestamp.EventDateFrom/ToClause) + sound index write-time pre-filter
(startedAfter - MaxFutureToleranceHours - 1h). The session-StartedAt RowKey was rejected as a
prefilter (WhiteGlove Part 2 emits weeks later).
- Raw date filters and ordering now use the business time (RawEventTime: OccurredUtc -> RowKey ->
system Timestamp); unparsable startedAfter/startedBefore is a 400; bare values are UTC.
- Same scan sat behind search_sessions_by_event, search_sessions_by_cve and the deviceProperties path
of search_sessions: all read Sessions by point read with the tenant their index row carries
(BatchGetSessionsByKeyAsync, IndexRowKeys.ResolveTenantId); the scan-based batch helper is gone.
MCP
- followNextLink(..., overrides): an explicit pageSize (and fields on the raw tools) applies to a
continuation in every nextLink-paginated tool; pageSize schemas lose their .default(),
pageSizeForCall sends the first-page default only on a first-page call.
- scanWithTimeoutFallback on the auto-exhaust tools: one retry with a halved pageSize on the same
cursor, marked retriedWithPageSize; timeout error text distinguishes continuation calls.
- Generated wire types regenerated (partial?: boolean).
Docs: docs/backend/raw-events-scan-budget.md (design, invariants, audit of the remaining unbounded
cross-tenant aggregations with live durations), business-timestamps.md consequences.
Deploy backend first (additive field), then MCP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Internal (Autopilot Monitor)" lane is labelled "never visible to the
tenant" and sits on the session page shown in every live demo, but it was
gated on the platform identity alone and stayed on screen when the
Global-Admin view was switched off.
visibleLanes() now takes the view flag as an explicit second argument and
combines it with the identity, so the rule also covers the read-only Global
Reader. The /annotations overview additionally filters the rendered ROWS to
the visible lanes: that list is not lane-scoped by the request, so an
internal row would otherwise surface there even with the lane dropped from
the filter dropdown.
Not a permission change -- the backend filters the lane for tenant callers
regardless (GetSessionAnnotationsFunction.FilterLanesForCaller) and owns the
write matrix.
Also fixes the retention-override banner: the bare warning glyph inherited
the base 16px size against 12px text and sat below the first line, with an
mt-0.5 hand-nudge that could not hold it. Replaced by a w-4 h-4 SVG whose
box matches the text-xs line box exactly, so items-start aligns it with no
margin -- and it now matches the icon style of every other alert box.
The IME log pattern controls needed no change: canEdit already reads
isGlobalAdmin && globalAdminMode, so demo mode reaches them through the
effective flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(web): demo presentation mode for live product demos
The Global-Admin view toggle only ever reached the nav and the scope hooks.
Every GA extra inside a tenant page was gated on the identity and stayed on
screen when the toggle was switched off -- including the /health-check build
cards, whose commit hashes hyperlink into the private repo, and the Decision
Inspector button on the session detail page.
New useGlobalAdminUi() (real GA *and* global view on) replaces the bare
identity check at the five visible GA surfaces, so switching the toggle off
now yields a genuinely tenant-admin-looking view.
On top of that a hidden demo mode, armed by a self-consuming ?demo=1
(persisted to localStorage, then stripped from the address bar), forces the
global view off for every consumer without touching the stored value, hides
the toggle itself and the platform role badges, and silently replaces
/admin/* with /dashboard rather than rendering the Access Denied card --
whose text would itself reveal that a platform area exists. ?demo=0 clears
it and restores the previous setting. An unrecognised value leaves the
stored flag alone, so a typo cannot drop the operator out mid-presentation.
Admin Mode stays untouched: a tenant admin has it too, so it belongs in an
honest demo. user.isGlobalAdmin is never faked globally -- that would drag
canEditConfig along and leave a GA without an own tenant-admin role unable
to demonstrate anything.
Also hides the settings gear when neither toggle applies, which previously
opened an empty panel for Operators and Viewers as well.
Presentation only. Every rule mirrored here stays enforced server-side
(TenantConfigValidation, GlobalAdminOnly -> 403); no backend file is
touched and demo mode is not a security boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(web): refresh generated page-lastmod
Regenerated by the build (scripts/generate-lastmod.js). The committed file
had drifted: about/privacy/terms carried stale dates and /buy, /help and
/plans were missing entirely, so the sitemap under-reported them.
Pre-existing drift, unrelated to the demo-presentation-mode change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2026-09-01 WG-seal soak on post-SignalIngress-fix builds (memory:
project_wg_seal_postfix_soak_residual) found sessions whose DecisionEngine
never sealed Part 1 while the agent kept running for hours - and telemetry
could not distinguish a frozen ingress worker from a deliberate Weak
sealing-classifier verdict, because the metrics snapshots carried no
pipeline counters at all.
Every agent_metrics_snapshot now carries the block. The fields are MONOTONIC
totals plus a monotonic queue peak - deliberately no momentary queue length:
a snapshot samples the integral, so any two snapshots prove whether the
worker ran in between (a frozen worker keeps signal_pending_count growing
while processed/applied stand still; a momentary gauge would alias between
ticks). Same field pattern the snapshot already uses for the spool
(spool_total_enqueued_count / spool_peak_pending_item_count).
- signal_last_ordinal / signal_processed_total (new ProcessedSignalCount) /
signal_pending_count / signal_reentrant_total: SignalIngress worker
progress; signal_queue_peak is a new lock-free Interlocked-max recorded in
Post (fast + slow path; worker-local follow-up items deliberately excluded)
- decision_stage + decision_last_applied_ordinal: from the immutable
DecisionState - answers the WG question directly ("minutes after the
tracker's whiteglove_complete and still not WhiteGloveSealed")
- wg_sealing_level / wg_sealing_score: only after the sealing classifier
produced at least one verdict (key ABSENCE, not Unknown/0 noise, for every
non-WG session) - separates "classifier said Weak" from "never ran"
Wiring is a read-only probe chain following the imeTrackerHealthProbe
pattern: EnrollmentOrchestrator passes () => _processor?.CurrentState into
IComponentFactory.CreateCollectorHosts (new optional decisionStateProbe;
test fakes updated), PeriodicCollectorLifecycleHost combines it with its
existing concrete-SignalIngress reference in BuildSignalPipelineProbe, and
AgentSelfMetricsCollector writes the fields fail-soft (a throwing probe
degrades to a snapshot without the block - pinned by test). The snapshot
event travels the collector/event path, which is independent of the signal
pipeline it measures - field-proven by the freeze cases, whose perf/metrics
events kept flowing while the engine stood still.
No backend/web/MCP change needed: no new event type, DataJson is free-form.
Tests: 5 new collector facts (full block, absence semantics x2, fail-soft,
no-probe compatibility) + 2 new SignalIngress facts (processed total stable
across drain; queue peak survives the queue draining - the property a
momentary gauge would not have). Agent suite 2812 green, solution builds
with 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad
Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer
tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad.
Ein neuer Provider haette beide anfassen muessen.
Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider —
WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST.
SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es
keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession,
ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification).
SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService
bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern.
Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token
gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb
ist das Gate eine Server-Regel, keine versteckte Dropdown-Option:
ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE
Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der
Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter
Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann.
Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und
wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft.
Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList /
RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin,
statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson +
GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload,
NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0).
Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von
Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im
Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus
SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar,
damit ein Speichern ihn nicht still verwirft.
Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den
SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate
invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests
decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub,
der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots
OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in
AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel
wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden
hiess also: das Sales-Team bekommt auch jede Security-Regel.
Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe
NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds
bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und
vereinigt ihre Ziele.
Rueckwaertskompatibel ohne Migrationsjob:
- Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot
einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und
uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet.
- GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor
zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert.
Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher
wegdriftet.
- Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel
traegt, also verhaelt sich am Deploy-Tag nichts anders.
- Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel,
deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles.
Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr
geschrieben — aufraeumen erst, wenn produktiv Channels stehen.
Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von
RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen
Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes
Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die
auch die Tenant-Liste passiert.
Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog
registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und
wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen.
Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext
mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen
Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der
gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt
ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine
Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf
positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine
Webhook-URL in den falschen Provider-Slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend): TenantTrialStarted + Event-Payload optional pro Regel
Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der
GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den
Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event.
RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden
per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist
fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad
haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still,
ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null)
ist ein Downgrade und bleibt bei TenantPlanDowngraded.
Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein
Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst:
ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen,
und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das
Informationsniveau aller bestehenden Regeln bleibt damit unveraendert.
ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch
eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und
ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln.
Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte
<= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext
dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer
gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein
stabiler Schluessel zum Verzweigen.
Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die
Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung,
schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben
ungetestet — Suite-Konvention ist, die reinen Kerne zu testen.
Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md +
docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events
Query-Surface), und ein Teil-Commit darin haette es mitgenommen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies
The ops-events read could only narrow by category and date window, so answering
"did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and
filtering it client-side — and for the MCP tool that client is a language model,
so the question cost a category-sized response in model context.
Ops-events filter surface (mirrors the audit-log filter pattern one-for-one):
- OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters +
pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters ->
OData clauses). BOTH filter builders call the appender — the single-category /
unpaged one and the per-partition fan-out builder that adds the RowKey bound —
so "no category named" cannot silently return unfiltered rows.
- minSeverity expands to an OR-set, not a range: Table Storage compares strings
lexicographically and the severity names sort Critical < Error < Info <
Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half.
The ladder now lives in OpsEventSeverity.Rank, shared with
OpsAlertDispatchService, so "Warning and above" in a read means what it meant
when the alert rules fired.
- severity is normalised onto the canonical vocabulary and an unknown value is a
400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an
empty page that reads exactly like "no such events happened". eventType stays
verbatim — stored rows outlive the vocabulary, so a retired type must remain
searchable in the historical window.
- Every filter value is folded into the continuation fingerprint and echoed on
nextLink; extras are appended AFTER the pre-existing category/tenantId
discriminators and skipped when empty, so tokens minted before the filters
existed still fingerprint identically. The tenantId drill deliberately stays
in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing).
- MCP get_ops_events gains eventType / severity / minSeverity plus a `days`
shorthand resolved client-side to a concrete dateFrom (a backend-side "last N
days" would re-resolve "now" on every page and blow the token fingerprint);
an explicit bound always wins over the shorthand.
Fixed alongside: the paged cross-category fan-out skipped Platform (Azure
Monitor alerts) because its category list had drifted from OpsEventCategory —
written from day one, invisible to every paged reader, still visible on the
unpaged full-table path. The list is now OpsEventCategory.All plus a reflection
coverage test.
That drift had a structural cause, so the same round closes it for the MCP as a
whole: wire TYPES reached the MCP from day one, but vocabularies did not — types
are erased at runtime and z.enum() needs values, so every tool schema hand-typed
its own copy with nothing to compare against. The copies had drifted:
search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete
— both real stored statuses), both event readers offered 4 of 6 severities (no
Debug, no Trace), get_ops_events named 6 of 7 ops categories.
- The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES:
session statuses, event severities, ops categories/severities/types,
annotation lanes/verdicts) from the manifest sections listed in
MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops
vocabularies. Freshness is pinned twice — byte-equality against a fresh
codegen run in the MCP suite, and the shared-manifests-in-sync CI job.
- Tool schemas derive their z.enum() from it and descriptions interpolate the
list instead of spelling it.
- vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..])
must sit on a baseline naming why it has no backend owner (MCP-local knobs,
plus rule type / connection type / CVE risk, which have no C# constants class
yet). A new hand-typed list fails, and so does a stale baseline entry.
- OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the
OpsEventService write sites use the constants (a raw literal now fails
OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection),
the manifest exports them, and get_resource(name="ops_event_types") serves
categories + severities + types to the model from the generated copy.
Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent.
Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an
older backend gets silently unfiltered rows back and presents them as filtered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: register the ops notification channels doc in the index and log
The concept doc landed with the feature commits; its index entry and log entry
were still uncommitted in the working tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three CmTraceTimeSkewRegression fires on 2026-09-01 (one tenant, sessions
e797117b / c06d639d / d7c8032b, agent 2.0.1445, each exactly -60.0 min,
residual 0.0, 85-88 % grid-conformant) all came from the same stale leg: those
sessions are pre-provisioning, their Part 1 ran on 2026-08-20 under agent
2.0.1409 (before per-line anchoring) and left 23-26 IME samples at -60.00,
while the Part 2 leg that completed that morning is clean at -0.16. The
terminal reconcile scans the whole event partition, so the old leg supplied the
majority of the IME samples and fired against a build the device had
self-updated to minutes earlier. Real skew, 12 days old, already fixed - and
>=821 pending pre-provisioning sessions with agent < 1412 are queued to repeat
it.
Event rows carry no agent version, so the split is drawn on ReceivedAt:
ResolveEraStartUtc walks the batch stamps of BOTH sides back from the newest
one and ends the era at the first gap > EraGapHours (2.0) - above any in-leg
upload gap (self-metrics every few minutes, session_stalled at 60 min idle,
maintenance sweep at 2 h), below every pre-provisioning handover. Median, grid
residual, conformity AND the distinct-batch guard now all run on the same
era-filtered sample set; the batch counters used to come from the full scan.
SessionSkewScan carries ReceivedAt per sample and its 20k cap keeps the NEWEST
samples (chunked trim) - keeping the head would have blinded the detector on
exactly the sessions large enough to reach it. The ops event reports
eraStartUtc plus the excluded per-side counts, so an inherited leg stays
visible without being alarmed on.
Tests: 3 new era facts, mutation-proven (EraGapHours = 100000 turns exactly
those three red). The handover fact also asserts that the old leg judged on its
own still fires, so it is the window that silences it, not a moved threshold.
Backend 5334 green, Agent core 2805 green, solution builds with 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zwei Fehlbefunde des 30-Tage-Reports als Warnung im Skill verankert (SignalR-Reconnect-Ratio,
Consent-Funnel aus Frontend-Events), Datenmodell korrigiert (/progress liegt auf portal.,
dcount(user_Id) ist wegen disableCookiesUsage keine Personenzahl, pageViews.duration ist 0,
exceptions-Tabelle ist leer) und die Event-Referenz vollstaendig aus dem Web-Code regeneriert.
Alle 35 Queries der Datei einzeln ausgefuehrt, 35/35 gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:
WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).
UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.
ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.
Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.
Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.
Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.
Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.
Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): typed API response infrastructure — IApiResponse marker, wire-settings single source, widened anonymous-body ratchet (batch 0 of typed-api-contract)
IApiResponse (Shared, with SuccessMessageResponse/SuccessOnlyResponse and the
ProjectedItems/WireContract attributes for the upcoming manifest export) plus generic
OkAsync/CreatedAsync/JsonAsync<T> overloads constrained on it, so anonymous success
bodies stop compiling once the legacy object overloads go; ApiJsonOptions is now the
single source of the production wire settings (camelCase, WhenWritingNull, string
enums) used by Program.cs AND the three endpoint-test harnesses (two serialized
PascalCase-with-nulls, one JsonSerializerDefaults.Web — all could pass while the
deployed wire differed); TypedResponseGuardTests replaces OkAsyncBaselineGuardTests
with the same ratchet mechanics over two frozen baselines (44 anonymous helper sites
unchanged, plus 134 anonymous success WriteAsJsonAsync sites across 76 files —
error-shaped bodies stay anonymous by design) and a flatness/location reflection
guard (IApiResponse implementers derive from object and live in Shared, protecting
JSON key order); ApiResponseWireParityTests carries the ordinal old-vs-new
serialization proof each converted site must add
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): Sessions/Annotations/Reports/Raw/Progress success responses are typed wire DTOs (batch 1 of typed-api-contract)
34 anonymous success bodies across 24 functions replaced with flat IApiResponse DTOs
in Shared (SessionApiModels/ReportApiModels + new Annotation/Raw/ProgressApiModels) —
minimal in-place transformation, call structure/status codes untouched, declaration
order == wire order, nullable exactly where a site can emit null (WhenWritingNull);
fields= projections stay IReadOnlyList<object> marked [ProjectedItems(SessionSummary/
EnrollmentEvent)], raw table rows stay PascalCase-verbatim dictionaries (no
DictionaryKeyPolicy — pinned by test), AnnotationWire and the distress-report
aggregations now build named item types instead of anonymous ones, and shared shapes
collapse to one DTO (SessionListResponse x4, SessionAnnotationListResponse x2,
SessionReportListResponse/GetSessionEventsResponse paged+unpaged via absent-null
nextLink); 54 new wire-parity facts serialize each OLD anonymous literal (independent
fixture copied from the pre-migration site) against the new DTO ordinally, with a
null case per vanishing key; ratchet baselines shrink 44->14 helper sites and
134->122 write sites; the three distress-report test files read the now-typed
aggregation items directly instead of via reflection-ToDynamic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): every anonymous API success body is a typed wire DTO — ratchet baselines empty, legacy object overloads deleted (batches 2-5 of typed-api-contract)
the remaining 141 anonymous success sites (Admin 36, Rules 24, Config 15, Graph 5,
Metrics 16, Vulnerability 17, Infrastructure/Global/Notifications/Bootstrap/
Diagnostics 20 incl. the 429 quota body in McpQuotaEnforcementMiddleware, plus the
14 helper-call sites) now return flat IApiResponse DTOs from Shared (new Admin/
Rules/Config/Graph/Metrics/Vulnerability/Infrastructure/Notifications/Diagnostics
ApiModels; GeographicApiModels extended) — same minimal in-place transformation as
batch 1: declaration order == wire order, nullable exactly where a site can emit
null, dual success/failure sites keep their status-code logic and reuse
SuccessMessageResponse, MCP-critical key names (records/summaries/inventory)
pinned; wire item shapes that were function-local (CustomsArchive Run/EntrySummary,
CpeMappingDto, UnmatchedSoftwareAgg, AutoResolve items, LogPatternTestResult,
CveExposureSummary/SeverityBreakdown/TopCve — records rewritten as flat classes,
netstandard2.0 has no IsExternalInit) moved to Shared so the manifest export sees
them; payload types that must stay in the Functions assembly (RuleDryRun,
PreviewWhitelistEntity, GlobalAdminEntity, HealthCheck, GlobalNotificationDto,
Backfill/Reclassification/MaintenanceResult, TenantAdminEntity,
TenantConfigFieldSchema) ride as object slots — wire-identical via runtime-type
serialization, proven by the parity facts; 126 new wire-parity facts total
(Admin 26, Rules 12, Config 17, Graph 7, Metrics 16, Vulnerability 19,
Infrastructure 21 + Dictionary-key-verbatim and null-value pins); both
TypedResponseGuard baselines are now EMPTY regression guards and the untyped
OkAsync/CreatedAsync(object) overloads are gone, so a new anonymous success body
no longer compiles through the helper and fails the guard through raw
WriteAsJsonAsync; deliberately untouched: error bodies (one anonymous shape by
design), auth/me (AuthDecisionResult.Body slot shared with error bodies, tests
reflect over it), CheckMcpAccess (conditional dictionary keys), raw-byte streamers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(contract): C#→TS wire codegen — shared-manifests v2 exports the full response-type graph as generated TypeScript (batch 6 of typed-api-contract)
SharedManifestParityTests now writes schemaVersion 2: the v1 catalog sections stay
byte-compatible (sectionFieldMap/signalrMessages/sharedManifestChecks untouched),
the consumer-less sessionSummary section is gone, and a new types section carries
every wire type — roots are all IApiResponse implementers plus [WireContract]-marked
types (SlaViolatorSession, ImeVersionHistoryEntry), transitively closed by the new
WireTypeManifestBuilder over refs, collections, dictionaries, enums and
[ProjectedItems] item types (208 types). Fields honour [JsonPropertyName]/
[JsonIgnore], get-only members export readonly, a nullable slot exports optional
(WhenWritingNull omits the KEY — explicit null survives only inside collections and
dictionary values, so the nullable flag lives on nested descriptors only), field
order follows STJ's derived-before-base serialization for the one legacy inherited
wire type (LocationSessionRow : SessionSummary), and unmappable shapes (non-string
dictionary keys, foreign BCL classes) fail the build instead of degrading to
unknown. C# <summary> texts travel as JSDoc: Shared builds XML docs now (doc-quality
warnings CS1591/1573/1574/1584/0419 suppressed — crefs to foreign assemblies cannot
resolve from Shared; CS1570 malformed-XML stays an error, which surfaced a raw
0x01 control char and an unescaped ampersand in existing comments).
generate-shared-manifest-types.js emits utils/wire-types.generated.ts (interfaces +
string-union enums, 2.7k lines) next to the lean literal module, npm run
generate:manifests wraps it, the vitest freshness test pins BOTH outputs, and a new
shared-manifests-in-sync CI job (guardrails-in-sync pattern) fails on drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hand-written response types re-export the generated wire interfaces (batch 7+8 of typed-api-contract)
types/session.ts, types/enrollment.ts and types/adminConfig.ts are re-exports over
utils/wire-types.generated.ts (Session = SessionSummary alias for the 25+ barrel
importers; adminConfig keeps only the excessiveEventAutoActionMode literal-union
narrowing and the client-side OpsAlertRule view) — the generated types are never
weakened, consumers adapt: durationSeconds is now optional as on the wire (int? —
consumers guard with ?? 0, which also retires the latent NaN-minutes display on
in-progress sessions), status is the SessionStatus union (fixtures on canonical
values; the generic string-sort test keeps its own fixture type), required bools
break hand-built fixtures once → new test/factories.ts makeSession/makeEvent
(Object.assign, not spread — Partial spread re-widens and tests must force
data: undefined); the string|null lifecycle branches were already truthiness-based.
Duplicate local types deleted: geographic-performance now uses the real
LocationSessionRow wire type (incl. DO fields), GlobalSearch the generated
QuickSearchResult, sla/page.tsx the generated SlaViolatorSession (status stays a
number — C# int, rendered via statusLabels); sessionExportUtils aliases
SessionExportEvent = EnrollmentEvent and derives RuleResultCsvData via Pick, CSV
output unchanged; the three structural ReplayInputEvent clones (InstallEvent,
ScriptInputEvent, DownloadEvent) are aliases now. docs: drift-guards.md replaces the
shrink-only-OkAsync section with the typed-wire-DTO contract, index.md/log.md
updated. tsc 0, vitest 957, eslint 0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tests): SlaMetricsServiceTests anchor sessions inside the current ISO week — the 'now - 1h' fixture crossed into LAST week during the first hour of every Monday (UTC), emptying CurrentWeek and failing the three snapshot asserts (CI on Mon 2026-08-31 00:20Z; pre-existing Monday-morning time bomb, unrelated to the DTO migration); the anchor is now clamped to the week's Monday 00:00, proven green inside the same failure window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The extra hint line under the search field made the header say the same
thing three times (subheadline, placeholder, hint). The Cloud PC guidance
now rides on the input's title attribute — the plain browser tooltip on
hover, no cursor change — and the subheadline no longer repeats the input
format. Same guidance is documented in the customer docs (Windows 365
Cloud PCs, Progress Portal).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The placeholder carried the Cloud PC instruction ("Cloud PC: the name shown
in the Windows App") and was clipped by the input width, so end users saw a
truncated sentence they could not read. Placeholder is now short ("Serial
number or device name..."), the Cloud PC guidance moved to a permanent hint
line below the field (wired via aria-describedby), the subheadline mentions
the device name as an accepted input, and the button no longer wraps on
narrow viewports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shutdown run allowed every logged-in user (explorer.exe owner) dynamically, so a backdoor account created after the startup scan and signed in before enrollment completed was scored 0. The WMI inventory is now read first and a logged-in name that is a local account is rejected for the account and the profile check (reported as logged_in_local_accounts); only Entra/domain identities — the only ones seen in real sessions — remain dynamically allowed for their profile folder.
Built-ins are recognised by RID 500/501/503/504 on the machine SID in addition to their English names: since disabled accounts are inventoried (88dfd331), localized Gast/Invité/Administrateur would otherwise flag every non-English device. kioskUser0 (Assigned Access autologon) joins the built-in list; the profile check skips junctions and $<32 hex> folders (cannot be an account name, pure payload noise). ANALYZE-ID-002 1.3.0 explanation mentions the new field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The path guards, the enumeration's reparse-point skip and the per-file attribute check all judged path strings, while the FileStream feeding the ZIP opened that string later (after earlier files were compressed). Under any recursive source folder a local user can write to, a subdirectory turned into a junction in that window made SYSTEM copy the junction target into the tenant-uploaded package.
PinnedSourceFolder holds each section folder open for the whole section (final path must equal the validated path, 8.3-expanded; FILE_LIST_DIRECTORY without delete share, so NTFS refuses renaming it or any ancestor) and opens each candidate with FILE_FLAG_OPEN_REPARSE_POINT, requiring the handle's final path to be exactly <canonical folder>\<relative path>; the archive is fed from that same handle. Manifest: FOLDER REJECTED (...) / SKIPPED (resolved outside validated folder) / FAILED (open). The race is pinned by a test that swaps the directory from the BeforeSourceFileOpen seam; on the previous code the same test copies the junction target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A crafted /sessions?id=x%2F..%2F..%2Fglobal%2Fy%23 deep link let the victim's browser resolve
dot-segments into a different API route and truncate builder suffixes via '#', turning UI
actions into attacker-chosen authenticated same-origin requests. Hydration now also requires
the returned sessionId to match the requested one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tenant hardware whitelist is stored as a CSV and split on ',' at
enforcement time. Manufacturer/model strings in the Rejected-Hardware
insights come from unauthenticated distress signals, so a value like
"Dell Inc.,*" added via "+ Mfr" appended a standalone "*" and silently
turned the manufacturer gate into allow-all.
- Web: shared hardwareWhitelist helper; "+ Mfr"/"+ Model" and the manual
editor neutralize ',' to the single-char wildcard '?' so a value can
never split the list.
- Backend: TenantConfigValidation.ValidateHardwareWhitelist in ValidateModel
(PUT + patch) rejects blank items, control chars, over-long/too many entries.
- Shared: ParseWhitelist trims entries so enforcement matches the editor view.
- Tests: 8 xUnit + 5 vitest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubmitSessionReport built the report blob name from the raw route sessionId. Route values are
percent-decoded, so an encoded '/'+'..' reached Azure.Storage.Blobs, which keeps separators, and
System.Uri collapsed the dot segments into a write outside the session-reports container.
- SubmitSessionReport / SubmitDiagFilesReport: 400 unless sessionId/tenantId are GUIDs
- SessionReportService: EnsureValidGuid at entry of both submit paths
- New Security/BlobNameGuard (flat-name invariant) applied in SessionReportService,
SessionReportDiagnosticsArchiveCopier (pre-check + upload) and the GA download function
- Tests: BlobNameGuardTests, copier rejects non-flat destination without storage calls
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult
(GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for
the whole suspension window and during an offboarding wipe. DecideAsync now denies 403
TenantSuspended when the caller's home tenant or the request's target tenant is currently
disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the
tenant; device/anonymous routes keep their SecurityValidator gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Welcome/farewell mails rendered DomainName unencoded while the full-model
config PUT persisted it verbatim, letting a tenant admin ship attacker HTML
in a signed product mail to an arbitrary recipient. Encode at Render() and
the built-in builders, preserve DomainName from the stored row on PUT for
every caller, and only seed a strict DNS host name at first login.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EffectRunner posts ClassifierVerdictIssued from inside DecisionStepProcessor.ApplyStep,
i.e. on the SignalIngress worker thread. With the bounded channel (256) full, Post's
back-pressure slow path blocked the channel's only consumer as a producer: nothing could
ever drain, every collector Post blocked behind it, and the whole decision/monitoring
pipeline froze silently until the max-lifetime watchdog.
Post now detects a call from the worker thread itself and routes the item to a
worker-local follow-up queue that WorkerLoop drains (iteratively, ahead of the channel)
right after the current item. Same processing path — ordinals, SignalLog, emit,
reduce, ApplyStep — just never blocking. Worker posts are accepted during the Stop
drain too, since the verdict belongs to the step being finished. ReentrantPostCount
exposed for observability/tests.
Tests: full channel + worker post releases a blocked foreign producer and processes the
verdict before the next channel item (fails against the previous code by hanging);
follow-up chain drained in order without recursion; verdict posted during Stop drain is
not lost. Docs: agent/decision-engine.md, agent/overview.md, log.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied
session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune
device id from the CN, or bootstrap code + serial) in primary-only Owner* columns.
SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it;
SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a
throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage.
Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch
without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was
absent. That header is client-writable, and certificate content alone carries no proof of private-key
possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable,
but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token
passed the function gate (Headers.Contains) while the validator treated it as no token and fell
through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29).
- Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths.
- IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired
BootstrapSessionService => 401 before the certificate stage.
- GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it.
- Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CollectLogsQuickConfigEnabled fires when a tenant admin switches on Hosted
diagnostics upload through the session Collect Logs quick-config dialog
(PUT ?intent=collect-logs -> write source portal-collect-logs); every other
enable/disable path emits DiagnosticsUploadEnabled/Disabled (PUT, PATCH,
revert, MCP). SessionActionQueued records portal-queued server actions.
DiagnosticsUploadConfigChange is the single definition behind the
diagnosticsUploadConfigured feature flag and the flip detection. Event types
dual-registered in the OpsAlertRules catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rotate_config only stored the fetched config in RemoteConfigService and never
merged it into the running AgentConfiguration, so the portal quick-config flow
(enable diagnostics upload, then collect) ran the diagnostics gate against the
stale mode=Off and failed with a generic diagnostics_upload_failed 4 ms later.
The action now runs the same ApplyFetchedConfig path as the startup fetch and
reports config_fetch_failed when the live fetch fell back to cache/defaults.
Skipped uploads return a DiagnosticsUploadResult.Skipped with the gate name
(diagnostics_skipped_mode_off / _not_configured / _onfailure_success) instead
of null, so server_action_failed names the actual reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.
- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
(bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.
- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
(no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md
Legacy role rows are inert until their binding is seeded - seed before deploying the backend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author-controlled relatedDocs[].url was rendered verbatim as <a href> on the session
detail, diagnosis and analyze-rules pages and never validated server-side, so a
Tenant Admin could store a javascript: URL that executes in the portal origin for
any viewer — including cross-tenant Global/MSP admins.
- Web: safeHttpUrl() guard (absolute http/https only) at all four render sites;
anything else renders as plain text.
- Backend: ValidateRelatedDocs on Create/Update — blank URLs dropped, non-http(s)
URLs rejected with 400.
- Tests on both sides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Microsoft serves IME from several versionless hosts carrying different rings at once
(secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the
canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools
each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no
msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later
sighting with a version-authoritative CSP URL re-queues an unarchived version at most
once per 24 h (MsiArchiveUpdatedAt + Queued stamp).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS
re-score ran on the nightly timer alone. Both walks move out of the timer into
VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the
nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for
a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the
minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the
page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST
vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a
manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new
AdminConfiguration fields.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.
NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.
CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0
(zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras
through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode,
server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with
enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless
fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare
listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results
(5 min) and resource reads (60 min) — the catalog is role-dependent, so never public.
Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID
Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any
request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache).
The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR
client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle).
Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity;
cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths.
Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A redeploy at an unchanged code sha (the documented way to refresh the
baked-in search_docs corpus) built an image with the identical tag;
az containerapp update saw the same image reference and created no
revision, so the verify step timed out against the old build (run
33215583943). Tag = sha-run_number; build commit/version stay separate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cloud PC users know the device name the Windows App shows, and the lookup
already accepts device names, so there is no reason to exclude them. The
monitored enrollment starts at first connect in Account Setup (v1 rail,
phases 4 -> 5 -> 7; device phases 1-3 run headless and are never declared),
so the Cloud PC layout lists only the steps the user can actually watch:
First sign-in / Account setup / Installing apps / Finalizing setup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The end-user page hardcoded the seven Autopilot v1 ESP phases and gated its live
app panel on currentPhase 3/5 — phases a Device Preparation session never
declares — so DevPrep users saw 0 % and no app progress until Finalizing.
Steps now derive from the shared resolvePhaseLayout (v2 layout, skip-user
layout), the v2 app step is promoted from app-event evidence, and the status
vocabulary maps AwaitingUser / parked pre-provisioned devices to a waiting
state and Incomplete to neutral — only Failed stays red. Cloud PCs are named
as unsupported.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case
was a relaunched agent re-tailing the IME log and uploading one burst holding
most of the session's IME samples with a continuum of ages whose median hit the
15-minute grid by chance. The tripwire now also requires >= 80 % of the IME
deltas (relative to the other side's median) to individually sit within 2 min of
some grid multiple; era-mixing regressions still fire. Fraction is carried in the
ops event message and details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Microsoft released Windows Autopilot device association on 2026-08-25. The
DevPrep association lookup leaves shadow mode and becomes an accepting method
of the device-validation gate (after Autopilot and Corporate Identifier, before
the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The
toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields
dropped, settings toggle shown to every tenant admin under Device Preparation
with the shared consent rule. Landing hero carries a release pill linking to
the docs; dashboard tip added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two analyze rules born from the pilatus-aircraft diagnosis (2026-08-27):
- ANALYZE-APP-017 "App Install Blocked by Another Installer (0x80070652)":
fires on app_install_failed with hresultFromWin32 -2147023278
(ERROR_INSTALL_ALREADY_RUNNING); confidence boosts for MSI exit code
1618 and for an ESP failure settling with the same code. The 08-26
burst failed 16 sessions deterministically at ~21 min (IME's fixed
msiexec-busy retry budget) right after a preceding installer left the
Windows Installer mutex held - previously only generic
"Enrollment Failed" fired, naming no cause.
- ANALYZE-NET-001 "Diagnostics Upload Blocked by Web Security Gateway":
fires on diagnostics_upload_failed answered by an HTML block page
carrying a recognizable security-product marker (McAfee/Skyhigh,
Zscaler, Netskope, ...). OnFailure diagnostics are lost exactly on the
sessions that need them; plain HTML errors without a vendor marker
(stale/stopped endpoint) stay excluded on purpose.
Both validated via MCP validate_rule (0 errors) and dry-run with
test_analyze_rule: fire on the burst session (confidence 100 / 90),
silent on a timeout-shaped failure and on a succeeded session that
carried unrelated app_install_failed events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:
- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
real-user desktop + zero app_install_failed reconciles to Succeeded
"completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
2.6% - the succeeded background, not the ~29% failure level - and the
sampled desktop half had clean app records. No desktop or any failed app
stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
(enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
the stream: all five observed maxlife:r5_awaiting parks expired unhealed
while live-agent parks upgraded 7/26. The maxlife ingest appends its
trigger event to the read stream so the fact is never missed; late
telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
noise), per-path share regression now needs >=5 baseline hits (new or
renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
into the silence/classifier-verdict group sums on both sides of the
2026-08-23 instrumentation cutover - dissolving the lift-124 silence
artifact. Episodes below the fire floors clear themselves.
Backend only; the new path appears in the calibration matrix dynamically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A delivery log names only the recipient, never the tenant the welcome mail was
for. The tenant console's search now matches the notification and contact
address as well as domain and tenant id; the quoted exact mode is unchanged, and
no other address is searchable (an admin UPN never receives platform mail and
would only widen the PII surface of the list).
The addresses live in PreviewWhitelist, not on the tenant config, so a new
GA/Reader-only GET /api/preview/notification-emails returns them as one map --
a single cross-partition scan instead of a point read per tenant. Both sides
join on the lowercased tenant id (address rows carry the JWT tid, a config
PartitionKey's casing is not guaranteed) and matching is case-insensitive,
because stored addresses keep the casing the user typed.
The map is best-effort in the portal: a failed read costs only the
search-by-address, never the tenant list. Addresses stay out of the list rows --
they are a search key, and remain visible in the tenant editor.
Match rules moved out of the component into tenantSearch.ts with unit pins, and
a catalog test pins that the plural route does not resolve to the per-tenant
entry one character away (which would demand a tenantId the route does not
carry, and hand a delegated caller a rescue path into the whole map).
Deploy: backend before web.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The activation page held the only address field in the product, and its redirect
effect fired on the first poll after auto-approve — roughly 60 seconds after
signup. Anyone who had not typed by then lost the field, no notification-email row
was ever written, and TrySendWelcomeEmailAsync deferred a mail that nothing would
send later. Nine of twenty-one August signups got a welcome mail; the last one
went out on the 21st.
It stayed hidden because every "not sent" branch logged at Information, and worker
application logs never reach Application Insights below Warning.
- Activation page holds the redirect until the address is saved or deliberately
skipped, and switches to an "Activated" state while it waits. Visitors who
arrive at an already-activated tenant are passed straight through as before.
- Recipient resolution falls back from the activation-page address to the tenant
contact address. The signup admin UPN is deliberately not a fallback: those
accounts often have no mailbox and the bounces cost sender reputation.
- WelcomeEmailSent / WelcomeEmailSkipped / WelcomeEmailFailed ops events make
every outcome visible, dual-registered in OPS_EVENT_TYPES.
Three defects in the same path, found while fixing it:
- The sent-marker was consumed before the send and never released on refusal, so
one provider hiccup suppressed a tenant's welcome mail permanently. It now
records delivery, not an attempt.
- A missing provider key logged at Debug, hiding the one condition that stops
every mail at once. Now Warning.
- The Global Admin resend endpoint reported success regardless of what the
provider answered. It is the manual fallback for exactly this failure, so it now
returns 502 and leaves the marker free when the message is refused.
The welcome mail is also no longer fire-and-forget: a discarded Task can be torn
down when the invocation ends, and its result is what the ops event reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi
signal" stat that simply vanished — on a location-gated device that reads as a
product defect rather than the Windows 24H2 gate that caused it.
No agent change was needed: buildSegments already merges every wifi_signal_info
into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in
with the sample that was already being read.
- NetSegment.dataLimitedReason, filled from the sample.
- Segments that never got a sample of their own (a mid-session reconnect emits
no wifi_signal_info) inherit it: the gate is a device-level setting and cannot
flip during an enrollment. A segment WITH a real reading is never overwritten
— pinned by a test.
- Tooltip: "Signal: unavailable — Location services off on this device" where
the percentage would be.
- Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping
the row.
network_state_change is deliberately left alone. That event has never carried
signalPercent or radioType — NetworkChangeDetector collects them into its
snapshot for change detection, but only before_/after_wifiSsid reaches the
payload — so there is no absent field there for a reason to explain, and the
SSID it does carry is unaffected by the gate. Adding it would ship a field
nothing reads.
Verified: web tsc + eslint (0 warnings) + vitest 893/893.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since 058248d9 the SSID survives the gate via the WinRT tier —
but signal, radio and channel silently vanish with no explanation anywhere.
When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now
carries wifiDataLimitedReason = "location_services_off" and the event message
gains "— signal unavailable, Location services off", so the reason reads straight
off the timeline. The portal's WiFi card matches that literal and names the
Intune setting that restores the full payload.
The claim is evidence-only: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting
we did not observe. IsLocationGateDenied is the single predicate, pinned at its
boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as
the published agent/portal/MCP contract.
New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can
be sized before anyone changes a policy:
search_sessions(deviceProperties: {
"wifi_signal_info.wifiDataLimitedReason": "location_services_off" })
Backend is untouched — the event payload is opaque DataJson with no projection.
network_state_change and the network timeline deliberately do not carry the
reason yet; that is a second payload plus web change for no extra insight.
Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings)
+ vitest 889/889, MCP tsc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A completed scan is a finding about the software inventory, not an
enrollment failure. Mapping the CVE risk onto Error/Critical made the
report read as a failed enrollment: the diagnosis page collected it as
failure evidence, MCP search lifted it on failure-intent queries, and the
timeline's Error filter filled up with clean sessions. critical/high now
map to Warning, everything else to Info; the CVE severity itself stays in
scan_summary and on the report panel's own scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments
(build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01).
Not a parser or language problem: every event that exists carries a full SSID,
so the failure is binary at the reader level.
Since 24H2 Windows gates WlanQueryInterface with
wlan_intf_opcode_current_connection — the only opcode we use — behind
precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt
only fires for a process in the user's context outside System32, so the agent
(SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it
resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none
— while the interactive user on the same machine reads the SSID fine via the
per-user NonPackaged value. That is why it tracks the image's location setting,
not hardware, driver or language.
netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its
multi-language labels never covered the case they were built for; more labels
cannot fix it.
- New WinRtWifiSsidReader: Microsoft's documented replacement
WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via
the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same
mechanism OobeStateReader already uses from the SYSTEM context.
- New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both
collectors had duplicated.
- The step diagnostics added in 2026-07 were logged at Debug while the default
level is Info, so they never reached a field log. The all-tiers-failed case is
now a Warning that decodes rc=5 into the location-gate explanation.
Signal, PHY and channel have no ungated source and stay null on tier 2;
deriving a percentage from GetSignalBars would fabricate precision.
Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured
live (532/604), and the full WinRT chain resolves and returns the SSID under
net48.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline search box now tokenizes the query instead of treating it as a
single substring: terms are AND-ed and a leading minus hides its matches
(e.g. -app_install_progress), following the convention every other search box
uses. Quoting keeps a term literal, so an exit code like "-1" stays searchable.
The negation is decided at a token's first character, before a quote is
consumed, and terms match the same fields as the positive search (event type,
message, source) joined by a newline so a term can never match across a field
boundary. The event counter follows the search and an active exclusion is
named in a pill next to the severity filters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Intune roots the chain is pinned to are shared by every Intune tenant, so
a valid certificate only ever proved "issued by Intune to someone". Tenant
scoping rested on the device serial, which is printed on the chassis. A
Mismatch now returns 403.
Switched on against measured data, not a hunch: 35,684 requests across 32
tenants since the shadow telemetry landed, every one a Match, with the
Warning channel independently verified to be carrying other traffic so the
zero could not be a telemetry artefact.
Only Mismatch blocks, and the rule lives in one function so "what rejects a
request" has a single answer. ExtensionMissing, Unparseable and
RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only
covers devices active in the window, so an older certificate or a
sovereign-cloud CA could still lack the extension, and rejecting on absence
would lock those devices out for something outside their control.
ExtensionMissing is the candidate for later tightening and has its own test
pinning the tolerance, so removing it must be a decision.
Evaluation fails open on its own exception — a defect on our side is not
evidence of a cross-tenant certificate. The 403 body does not echo which
tenant the certificate belongs to; that pair goes to the rejection log.
Device binding (stage 3) is untouched: still shadow, still Global-Admin-only.
Backend 4638/4638, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.
IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.
Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.
ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.
Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same gate the cert-device-binding shadow got in 35a3ed0c, applied to its
sibling. Measured on the live enrollment: 263 identical shadow lines for one
device off a handful of Graph calls, because the cache answered nearly every
request and the log sat outside it.
DeviceAssociationResult.AsCacheHit() copies rather than marking the shared
cached instance, so the first real lookup is never mistaken for a cache hit
and dropped. Transient outcomes are not cached and keep logging every time.
Backend 4633/4633, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the first live enrollment: 136 agent requests produced exactly
ONE Graph call, because the 30-minute positive cache absorbed the rest. So
the check is not a throttling risk (the per-request Autopilot serial
validator made 25 calls in the same window) and the positive TTL needs no
change — raising it would only delay noticing a wiped or removed device.
What the 136 requests did cost was 136 log lines all saying the same thing.
Gate the shadow log on ServedFromCache so it emits one line per real lookup.
Cached repeats carry no new information: the reported age just counts up
mechanically from the same enrolledDateTime. Outcomes that are never cached
(Transient, NoDeviceIdInCert) keep logging every time, which is what an
anomaly warrants.
AsCacheHit() copies rather than marking the shared cached instance, so the
first real lookup is never mistaken for a cache hit and dropped.
Backend 4631/4631, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.
IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.
Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.
The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.
Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).
Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-deploy verification of 249e39bc found zero AgentCertTenantBinding rows
across 32k agent requests. Cause: the Match outcome was emitted with
LogInformation, and worker-side ILogger below Warning never reaches App
Insights (provider default rule is Warning+, host forwarding of worker logs
is off). The bulk outcome was therefore invisible, which is indistinguishable
from the code never running — the shadow telemetry had no denominator.
Stamp every outcome onto the request row instead (FunctionContext.Items ->
RequestTelemetryMiddleware -> CertTenantBinding dimension). That row already
exists per request and is unsampled, so the denominator costs no extra
telemetry. Non-Match outcomes keep their Warning trace with full context.
FromCache existed only to sample the dead Match line and is removed with it.
Backend 4606/4606, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A chain-valid Intune MDM certificate only proves "issued to some tenant" —
the pinned roots are shared by every Intune tenant. Tenant scoping rested
entirely on the device serial in the downstream validators, which is not a
secret. Bind the tenant stamped into the auth cert to the requested tenant.
The Entra tenant id sits in OID 1.2.840.113556.5.14 of the cert the agent
already presents for mTLS, so this needs no Graph call, no consent and no
agent release. Not 5.6 (Intune Account id, a different GUID) and not 5.4
(device id, stored as 16 bare bytes) — the decoder handles both encodings
and now lives once in Shared, used by agent and backend.
Stage 1 observes only: ObserveCertTenantBinding returns void and swallows
its own exceptions, so it cannot fail an otherwise valid request. Match is
logged only on cert-cache miss, keeping the denominator without per-request
volume. Grep marker for stage 2: CERT-TENANT-BINDING-SHADOW.
Backend 4604/4604, Agent V2.Core 2519/2519, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intune's MDM device cert stores the DeviceId in OID 1.2.840.113556.5.4 as a
bare 16-byte GUID with no ASN.1 wrapper, so the strict OCTET-STRING parser
bailed on the tag check and returned null. Verified against a live Intune
device cert: 5.4 is 16 raw bytes, while 5.6 / 5.11 / 5.14 carry the usual
04 10 wrapper.
- ParseGuidFromAsn1OctetString -> ParseGuidFromExtension (the old name no
longer describes what it parses); both encodings now handled, with the
shared LE-GUID construction pulled into ToGuid(). The forms cannot
collide: a wrapped 16-byte GUID is always >= 18 bytes.
- Document the Intune MDM cert OID arc on the class: 5.4 DeviceId,
5.6 Intune AccountId (NOT the Entra tenant), 5.14 Entra TenantId,
5.11 undocumented GUID, 5.15/.16/.17/.18/.19/.23 INTEGER enums.
- 13 tests pinning both encodings plus the malformed-input rejections,
including a bare GUID whose first byte is the 0x04 OCTET STRING tag.
No behaviour change for the MS-Organization-Access probe the agent uses;
TryGetTenantIdFromCert is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The built-in default config shipped empty ImeLogPatterns/GatherRules lists, so a
session that got neither a live fetch nor a cached config ran with the entire IME
tracking pipeline dead — no app installs, no script results, no health-script
compliance, no IME ESP-phase signals — although the agent binary carries all 37
action handlers. The scalar knobs never drift (shared AgentConfigResponse property
initializers are also the backend's fallbacks), but the catalogs cannot come from
initializers.
BuiltInConfigCatalog embeds the SAME CI-combined rules/dist artifacts the backend
embeds and serves (ime-log-patterns.json, gather-rules.json) and feeds the enabled
entries into CreateDefaultConfig. Single source by construction: each agent release
snapshots the catalog state of its own build, so the offline fallback can never
drift further than the running agent version itself. A live fetch (startup,
post-registration recovery, rotate_config) always wins. Fail-soft: a load error
degrades to the previous empty-list behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (tenant aebdce78 audit 2026-08-24, session ba828038): on boot-time
relaunches the agent starts before the Wi-Fi link is up. The Phase 4 config fetch
had no network gate (98f8114d only guarded Phase 6 registration), burned its
10/30/60s retry budget into a dead link and stranded the whole session on built-in
defaults (ConfigVersion=0) — up to ~50% of that tenant's sessions, with no
self-heal short of a manual rotate_config action. Not a version regression: rates
varied wildly per day within the same agent build; the failed requests never
reached the backend.
Two complementary layers:
- RemoteConfigService gates the initial fetch on the shared NetworkLinkWait poll
(60s cap — longer than registration's 15s because the fetch runs first), so the
retry budget starts on a live link. rotate_config stays ungated (latency-bound).
- Phase 6.5: after registration proves the backend reachable, TryRecoverConfigAsync
refetches once and AgentRuntimeConfig.TryRecoverAfterRegistration re-applies the
live config (same merge/integrity pipeline as Phase 4). LastFetch* keeps the
original failure record; remote_config_fetch_failed reports it with a recovered
flag at Info severity, agent_started flags remoteConfigRecovered.
Deliberately not re-run in recovery: kill-signal check (telemetry-channel control
signals cover post-registration kills) and endpoint migration (next start).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-verified after 0d5e2572 deploy: the worker filter removes every 404/412/409
and worker SignalR row, but the SignalR output-binding calls are emitted by the
host process (SDK rdddsc) and only reachable at ingestion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One CVE commonly appears under many findings of the same report (e.g. one
RD CVE across 14 installed RD components). Each occurrence produced its own
concurrent upsert on the same PK/RK, so the stored SoftwareName/OverallRisk
was race-dependent. Collapse to one entity per CVE (highest CVSS wins,
IsKev OR-ed, OverallRisk max) and write with Parallel.ForEachAsync(8) and
per-entity fail-soft logging instead of Task.WhenAll + one outer catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Timer-thread Post() appended to a bare List while the test polled it in a
SpinWait foreach -> 'Collection was modified' (run 32644425162, no assert
text). Posted now returns a locked snapshot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both sliding-window limiters re-Set the cache entry without its eviction callback.
The replaced entry fired the callback asynchronously, removing the per-key lock while
a new one was already handed out, so two threads could mutate the same List under
different locks; the replacement entry never cleaned its lock, leaking one object per
key for the process lifetime.
The history now lives in a RequestBucket together with its own lock: same list ⇒ same
lock, no callback, no dictionary. Bucket creation is serialized (GetOrCreate is not
atomic) behind a lock-free TryGetValue fast path. The redundant re-Set is gone — the
lookup already refreshes the sliding expiration.
Concurrency tests (16 threads × 500 requests, one key) fail 3/3 on the old code and
pass on the new one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StartupTelemetryService emits BackendStartupMs (process start ->
ApplicationStarted) and BackendTableInitMs as App Insights custom
metrics with version/commit/tableInitFullPass/instance dimensions;
TableStorageService exposes LastInitialization. MCP server writes one
{"type":"boot","readyMs"} stderr line per start next to the
tool_call lines so cold-start time is queryable as a trend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cold start point-reads a SHA-256 over TableNames.All stored in
AdminConfiguration/SchemaSentinel/tables instead of issuing 66
CreateTableIfNotExists calls. Missing/stale sentinel runs the full
idempotent pass once and rewrites it. SessionsIndex startup backfill
only after a full pass, behind a conditional-insert claim (one instance
under scale-out); IsSessionIndexEmptyAsync no longer treats errors as
empty. Daily maintenance runs IStorageInitializer.EnsureAllAsync to
repair out-of-band table deletions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop per-row window7/baseline28.sessions (duplicated trend denominators), make
reEnrollRatePct/lift always present with null = withheld, add minSharePct/top
tail trimming that never drops override-carrying rows and reports what was
omitted. Backend wire shape and admin page untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First production pass alerted on agent:whiteglove_pending (two tenants in a
WhiteGlove rollout week) and agent:complete_soft (a Continue-Anyway tenant) —
customer workflow mix, not classifier drift. agent:* and register:* are now
ineligible for share_regression (still in the matrix and the silence group);
episodes opened by the broader gate re-arm on the next pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cross-tenant StartedAt-only date-range query is a full-table drain in
Table Storage; time attribution, device journeys, verdict calibration and the
calibration radar's tenant discovery each ran their own — four identical reads
per 2h tick. RunAllAsync / RunManualAsync now load the window once through
GetMaintenanceWindowSessionsAsync (MaintenanceSweepSessionProjection: the union
of the sweeps' columns, drops FailureSnapshotJson/geo/OS/diagnostics) and the
sweeps slice it by StartedAt. MaintenanceSweepProjectionEquivalenceTests pins
that every consumed field survives the projection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies the rule-regression statistics (7d vs 28d, lift >=2, Wilson-separated,
small-n gates) to the verdict-calibration rows: per-path share regression,
sweep+maxlife silence-share regression (agent liveness signal) and an
absolute r6 evidence-gap gate. Episodes live in the notification tracker
(verdictcalibration|kind|path|status), fire one VerdictCalibrationDrift ops
event (operator-only, no tenant bell), re-arm under 1.5x / 15%, and surface as
alerts[] + a Drift pill in the calibration matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Daily (tenant x StartedAt date) buckets per verdict path: count, derived
attribution, 7-day re-enrollment proxy (eligible/re-enrolled), and the
correction stream attributed to the PRIOR path (admin / late completion /
other). Written by the 2h maintenance sweep after the device-journey pass,
180d retention, regenerable. Read via GET global/metrics/verdict-calibration
(GlobalReadOrAdmin, 7d-vs-28d share trend, rates withheld below n=20),
the get_verdict_calibration MCP tool, and the admin metrics section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every status writer now declares the code path that produced the status
(VerdictPaths vocabulary, stamped for all statuses — unlike FailureSource
which is Failed-only), the timeout classifier returns its rule id, and a
write that overrides a prior verdict preserves it as PriorStatus/
PriorVerdictPath (admin mark, late completion, retro). Pre-instrumentation
rows derive a path read-side (VerdictPathDerivation, legacy:* when
ambiguous). Foundation for the verdict calibration aggregate + radar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kiosk tenant audit 2026-08-23: 836 of ~970 self-deploying sessions in one week
were parked AwaitingUser ("Account Setup 0/5") and graduated to Incomplete 51h
later although the devices were finished — the profile has no user phase, so the
classifier's rule 5 was asking for evidence that can never arrive.
- EnrollmentTimeoutClassifier: new rule 1c IsSelfDeployingProvisioned (profile flag +
Device Setup all-succeeded + no explicit failure) => Succeeded reconcile with an
honest reason; isSelfDeployingProfile passed by every caller (sweep stage 1+2,
max-lifetime verdict, late-telemetry heal, legacy retro mode).
- MaintenanceService stage 1: TryReconcileSelfDeployingAsync gate before the Stalled
marker (mirrors the WhiteGlove Part-2 gate), audit counter SessionsSelfDeployingReconciled.
- LegacyReclassificationService: new admin mode self_deploying_silent (heal-only,
dry-run by default) + GetSelfDeployingSilentSessionsAsync for the existing backlog.
- Agent SessionRegistrationHelper: wait up to 15s for a network link and retry 6x
(~62s) instead of 5x (~30s) so the BootTrigger relaunch on Wi-Fi no longer exits 7
before the link is back (the mechanism that left these sessions silent).
- Tests: 6 classifier cases (gate, within/past grace, user-driven guard, failure,
pre-Device-Setup), 2 registration tests; docs/backend/silent-session-classification.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Below `sm` the path owns the first line(s) and wraps in full (flex-wrap,
basis-full, break-all) with badges and controls flowing onto the next line —
a path truncated to "C:\…" is useless on a phone and a tooltip cannot be
hovered there. From `sm` up the row stays a single truncated line. The
"N file types" pill is shown on every width again now that the badge line
has room.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings section loses both pills; the vulnerability report footer keeps the
best-effort CVE matching note without the Experimental prefix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The diagnostics package's built-in collection is now ONE catalog in Shared
(DiagnosticsBuiltInSections.All): the agent iterates it when building the ZIP and
the backend serves the same list to the portal (GET /api/diagnostics/paths,
MemberRead), so what administrators see is exactly what the agent collects.
Agent
- New conditional sections: RealmJoin client/package/Chocolatey/per-user logs
behind the tenant's RealmJoin Watcher toggle (mirrored onto
AgentConfiguration.EnableRealmJoinWatcher by RemoteConfigMerger) and the Device
Preparation bootstrapper event log behind the deterministic WDP marker (probe
once per build, fail toward Classic). ZIP layout mirrors the disk
(RealmJoinLogs/Windows|Choco|User). Every skip is a package-manifest line.
- Built-ins bypass DiagnosticsPathGuards by design (reviewed code); caps,
reparse-point skip and evtx export still apply. No ConfigVersion bump.
Backend
- GET diagnostics/paths (MemberRead, JWT-scoped) with the catalog + global paths.
- MergeDiagnosticsLogPaths: global ∪ tenant dedup (trimmed, case-insensitive) —
duplicates used to produce duplicate ZIP entry names.
Web
- Collapsed read-only built-in list (RealmJoin on/off in the tenant header,
neutral in the Global-Admin view), one-line DiagnosticsPathRow for every entry,
add-row above the lists; tenant context no longer fetches /api/global/config.
Tests: agent RealmJoin on/off, no user session, bootstrapper evtx, probe failure,
merger mirror; backend catalog invariants, payload shape, dedup, policy; web
builtInSectionDisplay. Docs: docs/agent/diagnostics-package.md (+index/log).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both toggles gate collector hosts / device behavior, not IAgentAnalyzers, and
tenants looked for them under Agent Settings. RealmJoin Watcher now sits in the
Agent Collectors card, Keep Awake During User-ESP in Agent Parameters; OOBE
console detection stays with the security analyzers. Section ownership in
SECTION_FIELD_MAP and the per-section reset handlers follow, the Agent Settings
Save/Reset bar moves below both cards it saves, and the Analyzers sidebar
description names all four checks. Wire contract (AnalyzerConfiguration) and
agent are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Back to the 838da4cc header: one facet gradient, three-row brand mark, 113px
band, clip-path edge for clients that support it. Clients without gradient
support get a flat band instead of band-coloured blocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Row 1 keeps the single facet gradient and adds a top-right cell with its own
dark/light split (the corner facet); row 2 draws the slanted bottom edge as a
green/white gradient in three band-coloured cells. Pixel-measured against the
reference render: band boundaries identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Outlook web/iOS render exactly one linear-gradient with hex colours and % stops
(multiple layers / rgba stops are dropped), and dark mode inverts cell
backgrounds but not border colours — the white cell in the mark became a black
bar. Facets are now one gradient (top-right light corner dropped), the mark's
middle row is a white-bordered div.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Triangle / white cell + triangle / triangle, no overlays, no transparent td
borders; matches the reference mark in a side-by-side zoom render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified side-by-side against the reference render (scratch header-render.html):
three layered half-plane gradients reproduce the light/dark facets, the mark is
a CSS trapezoid plus two green wedges for the slanted right edge and the
descending bottom edge. Still no images in the mail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
112deg stripe gradient, 13/20/2 border-drawn mark, clip-path edge — the version
the user signed off on; later 'improvements' drifted from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against the original SVG mark in a Chromium render; the gradient keeps
the exact stop count that rendered in Outlook earlier.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- EmailTemplateService: override-or-built-in resolution per send (PreviewConfig
table, partition EmailTemplates, 5-min cache, {{domainName}} placeholder,
30k limit); send path fail-soft, admin read path strict
- GA endpoints global/email-templates/{kind} (GET/PUT/DELETE) and /test, which
mails the effective template or an unsaved draft to the caller's tenant
contact address
- portal: Settings → Email Templates with preview frame, test send, HTML editor
- mail header: brand mark drawn with CSS borders, no images left in the mails
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CSS border trapezoid did not reproduce the mark's shape across clients; the
mark is brand identity, so it is the one image in the mail (empty alt). Stripes
now run top-left to bottom-right, thin/wide/thin as in the reference banner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Progressive enhancement: solid bgcolor band + text wordmark everywhere; the
brand mark is a CSS border trapezoid; diagonal stripes (linear-gradient) and the
slanted bottom edge (clip-path) render where supported and degrade to a flat
band elsewhere. No remote images left in either mail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Solid bgcolor band with the wordmark as real text; only the 36px brand mark is
an image (empty alt). Replaces the full-width header PNG, which collapsed to a
bare green bar on clients that block remote images (iOS Mail).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- header is a hosted PNG (www/email/header.png, rendered from the brand mark +
wordmark) on a solid bgcolor band; mail clients drop CSS gradients, which left
the old header white
- welcome mail links "sign in" to the portal, active-development banner removed
- em dashes replaced with en dashes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- EmailService + IEmailService: Mandrill messages/send via typed HttpClient with the
Notification resilience policy; provider confined to one transport method and the
Email:* settings (ApiKey, Endpoint, FromAddress, FromName). Resend NuGet dropped.
- open/click tracking explicitly off; sent/queued/scheduled = ok, rejected/invalid = warn
- callers (TenantApprovalService, PreviewWhitelistFunction) depend on IEmailService
- EmailServiceTests pin wire format, result semantics and fail-soft contract
- privacy page + OKF welcome-mail doc updated for the new sub-processor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Discord (provider 30) renders alerts as a single embed with Discord's
field/length caps; actions become markdown links since webhooks cannot
post buttons. Generic JSON channels gain an optional per-channel signing
secret: requests carry X-AutopilotMonitor-Timestamp and
X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"),
computed over the exact posted JSON with same-named custom headers
stripped first. Secret is redacted for readers and restored by channel
id on save; validation enforces 16-128 printable ASCII characters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retention was the only enforcement point that destroyed state on a
Pro -> Community downgrade: the 12-hourly sweep clamps to the Community
cap at read time and would immediately start hard-deleting the
90-365-day band (trial expiry identical, silently).
New policy: for 30 days after losing Pro
(FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap
keeps applying. Anchor model: ProDowngradedUtc (backend-only field,
patch-denied + revert-protected) stamped/cleared by the plan endpoint's
new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers
explicitly ended trials; a planTier downgrade under an active trial
deliberately does not stamp); trial expiry anchors read-time via
TrialExpiresUtc. days<=0 (infinite) stays untouched.
Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService
dep) plus TenantRetentionGraceExpiring/Ended from the daily
TrialExpirySweep with the same stateless window mechanics, emitted only
when stored retention exceeds the Community cap; all dual-registered in
the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam
instead of inline DateTime.UtcNow. OKF doc
backend/plan-downgrade-retention-grace.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community/Pro comparison cards extracted into a shared module
(components/plans: planData.ts as single copy source, PlanCards.tsx with
marketing/portal surfaces) so the portal Plan section and the new public
/plans page can never drift. Pro price is one constant (null renders
"Pricing - announced soon").
New public pages following the /help pattern: /plans (free-first
messaging, nav item "Plans") and /buy (direct purchase + Microsoft
Marketplace as coming-soon channels). Landing nav, footer, FinalCta and
get-started now link to /plans instead of the docs plans page; portal
Plan section links to /buy via absolute www URL. Purple utilities
neutralized inside .dark .landing-v2; sitemap/robots/lastmod cover
/plans, /buy and the previously missing /help.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community tenants see a small "Community Edition" line under the brand
wordmark as a standing reminder (and nudge toward upgrade options); Pro
tenants (incl. trial) keep clean chrome. Label renders only after the
feature-flags fetch confirms the edition - no flash for Pro while loading.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IME reports skips and uninstall enforcements through the same completed
events, so both panels counted them as successful installs/downloads and
their pills contradicted the app_tracking_summary headers (session 502274b4:
"2 of 8 installed" vs "6 completed").
- Install fold: app_install_completed with state Skipped -> Skipped;
successful uninstall intent -> new terminal state Uninstalled with its own
pill and neutral row badge; completed now counts only real installs.
- Download fold: rows are classified by observed byte activity, never by
intent (Win32 uninstalls re-download the full package and stay visible
with an Uninstall badge); 0-byte rows sit behind a "no download" toggle.
- Both panels render tracker-known pending apps (summary pendingNames)
behind a "pending" toggle, so every "X of Y" header fully adds up.
- Headers wrap on narrow screens (flex-wrap + gap instead of space-x).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds www.autopilotmonitor.com/help as a mail-linkable page describing how
to get help: Report Session in the portal for session-specific problems,
GitHub Issues for everything else, LinkedIn for private contact, plus
self-service links (docs, FAQ, announcements, changelog) and the private
security-advisory path. Registered as public path in hostRouting (guard
test pinned) and linked from the site footer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fairstone.ca analysis 2026-08-21: after the reseal-reboot technicians routinely
power devices off at the logon screen to box them. Both silence paths (2h
maintenance sweep, agent stall probe) then labeled the expected parking state
"Stalled - Agent silent", and stage 2 later reconciled it to Succeeded on the
Part-1 whiteglove_complete with a reason claiming Account Setup completed.
- EnrollmentTimeoutClassifier: shared pure gate IsWhiteGloveAwaitingUser
(pre-provisioned + ResumedAt + Device Setup provisioned + zero user evidence
since resume) with one canonical customer-facing reason; new rollup field
HasEnrollmentComplete separates real completions from Part-1 whiteglove_complete
- ClassifyTimedOutSession: gated sessions are AwaitingUser within grace and
reconcile past grace with an honest WhiteGlove reason; on resumed sessions
whiteglove_complete no longer counts as terminal-complete evidence (rule 2),
so partial user progress falls through to the honest AwaitingUser/Incomplete rules
- MaintenanceService stage 1 + session_stalled ingest mapping apply the same
gate (fail-soft to Stalled); sweep audit now carries SessionsAwaitingUser
- Callsites (stage 2, max-lifetime verdict, late-telemetry reconcile, legacy
reclassification) pass IsPreProvisioned/ResumedAt through
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fmtDuration rolls hours into days above 24h ("768d 8h" instead of
"18440h 58m") - minutes are noise at day scale. Surfaced by the first
fleet session with a CMOS clock years off (0bd4af1c, 2024 -> 2026 step);
also keeps the slanted clock-marker label inside its headroom. Test
pinned on the real fleet delta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from
the Windows event log with its original timestamp, ~14min before the first
real activity) became the partition's earliest row and re-anchored the
session's StartedAt, inflating the duration toward pre-enrollment OOBE idle
time. With the watcher's 24h backfill lookback, the 2h backward-shift guard
only caps this effect, it does not prevent it.
SystemTimelineWatcher rows (clock steps, sleep episodes) are environment
observation, not enrollment activity, and are now excluded from all three
anchor derivations: the Events-table earliest-row probe (new
IsSessionAnchorEligible, probe scans past ineligible rows), the ingest
batch's EarliestEventTimestamp fold, and the web's client-side
enrollment-duration derivation. The events themselves stay on the timeline
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A left-edge "Clock -3h 0m" label collided with the HTML legend above the
band (first live fleet session d0c5b672). Marker-label headroom grows to
48 units so long labels stay inside the SVG, and the drawing content is
now inset 64 units on BOTH sides - the left inset mirrors the existing
right one, giving edge labels room and a calmer, symmetric frame.
Verified against the live session carrying the first system_clock_changed
event (backfilled step -> marker only, no rebase, as designed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consume the two new agent events:
- system_clock_changed: live-observed steps (timeDeltaMs authoritative,
>=30s; sub-30s NTP nudges ignored) split the stream into clock eras and
rebase every later timestamp by the cumulative delta, so the axis shows
true elapsed time. Backfilled steps (pre-agent, replayed from the event
log) are marked but never rebased. Each step gets a slanted "Clock +1h"
lifecycle marker with old->new time and reason in the tooltip.
- system_sleep_episode: episodes (emitted retroactively at wake) carve an
"Asleep" segment out of the active network instead of attributing the
quiet period to it; tooltip carries kind (Modern Standby/sleep), wake
source and AC/battery. Episode boundaries get the same era correction
as the event that carried them.
Stats row gains "Asleep total" and "Clock changes"; the event list
includes both new types (now titled "Network & system events").
Layout: drawing content stops 64 units short of the right viewBox edge
(CONTENT_W), so right-edge lifecycle labels slant up-right like all
others instead of flipping left.
7 new vitest cases pin era rebasing (live/backfilled/nudge), sleep
overlay incl. era-corrected boundaries, and hotspot confidence.
Live verification against fleet data pending - the emitting agent was
released minutes ago; no session carries the events yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two enrollment blind spots become timeline ground truth from the System event
log via one EventLogWatcher (WindowsUpdateTracker pattern, 24h backfill with
RecordId watermark recovering pre-agent records):
- system_clock_changed (Kernel-General 1): oldTime/newTime/signed timeDeltaMs,
reason and the setting process. 2s floor suppresses w32time 1ms micro-slews;
steps >= 5min are Warning + immediate upload. Payload instants are the
authoritative source (event timestamps can be clamped server-side).
- system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate,
Kernel-Power 507 Modern Standby; 506 deliberately unconsumed): kind,
enteredAt/exitedAt, durationSeconds, wake source / AC state. 60s real-sleep
floor filters Modern Standby screen-off scenarios.
ConfigVersion 39 (Collectors.EnableSystemTimelineWatcher +
SystemEventBackfillLookbackMinutes). Both types are non-activity on agent and
backend (idle/stall clocks unaffected; a sleeping device stays Stalled).
Consumers: TimeJumpBadge gains the ground-truth cause "clock-set"
(classifyTimeJump matches backward display steps against recorded clock sets);
EventTimeline badge blocks for both events plus the previously bare
power_state_change; SessionInfoCard keeps the wall-clock duration and adds a
standby note; TimeAttributionCalculator builds SleepSpans (RebootSpan clone,
AttributionVersion 3) persisted as SleepSeconds/SleepSpansJson and rendered in
TimeAttributionLane; ANALYZE-DEV-012 (info, single) fires once per session for
episodes >= 5min - deliberately the only new rule, clock findings stay with
DEV-008 to avoid duplicate-finding flood.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generated PowerShell command now uses the readable -Features form
(first catalog feature as the example) instead of raw permission strings;
a note below lists all available values incl. the All meta-feature and
how to fall back to -Permissions via Copy permissions. The section links
to the Optional Graph Permissions customer docs (via DOCS_URL registry),
and the "What this script does" bullet reflects the Cloud Shell
ambient-identity sign-in. Script help no longer claims the admin UI
generates the -Permissions form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gray lifecycle dots (Reboot / Desktop / Completed) were hard to hit
and mute without hovering. Their label is now written directly at the dot,
slanted up-right (flipping up-left near the right edge so it stays inside
the drawing), with the band shifted down to make headroom. The whole
marker group (line, dot, label) shares an enlarged invisible hit target,
and a label is suppressed when its neighbor sits too close - the tooltip
still carries the details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An SSID matching the typical iPhone hotspot name (".. iPhone") is strong
evidence on its own — such networks now get a plain "Hotspot" badge, while
subnet-only evidence keeps the hedged "Hotspot?". Tooltip and footnote
explain the distinction; detection also fires on iPhone-named SSIDs
outside the Apple hotspot subnet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Taskbar-style chips in the progress page status header showing the
device's last-reported power state (AC/battery with percent, low-battery
warning) and network state (WiFi with SSID tooltip + signal tiers,
Ethernet, offline). Derived purely client-side by folding the event
stream the portal already downloads (power_state_check/change,
network_interface_info, network_state_change, wifi_signal_info) - no
backend, API, or auth change. Chips render only while InProgress and
disappear gracefully for sessions from agents without these events;
devices without a battery show no power chip (Windows-tray parity).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All is a script-side convenience that expands to every GraphFeatureCatalog
feature (works with -Revoke too). The catalog sync test now pins All in the
ValidateSet while keeping it out of the catalog comparison, and the admin UI
mentions the shorthand next to the generated grant command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New /sessions/network-timeline?id=&tenantId= page visualizing every network
the device was on during enrollment: one colored segment per network
(Ethernet / WiFi SSIDs, fixed-order validated palette), hatched offline
gaps, ESP phase context lane, lifecycle + connectivity-check markers with
hover/tap tooltips, stats row and the raw network event list.
Segment model derives from network_interface_info + network_state_change
(before/after), enriches SSIDs from wifi_signal_info, infers missing
post-reconnect SSIDs via matching gateway, and flags probable smartphone
hotspots from vendor-default subnets (Apple 172.20.10.x, Windows
192.168.137.x, legacy Android 192.168.43.x) - always as a heuristic badge.
Axis is honest: linear per segment, minimum width for short segments,
boundary ticks show real times. Mobile scrolls horizontally.
Entry point: "Network timeline" deep link in DeviceDetailsCard's Network
section via new networkTimelineUrl() builder. Route registered in the SWA
config (exact routes before the /sessions/* wildcard, incl. the index.txt
RSC route) and excluded from LegacyPathRedirect's /sessions/{id} rewrite
via the new static-siblings set (test pinned).
Planned follow-up once the agent emits them: clock-change events (rebase
axis into clock eras) and standby/Modern-Standby events (render sleep as
its own segment kind).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Try Connect-MgGraph -Identity first (Azure Cloud Shell issues a delegated
token for the signed-in admin, avoiding the device-code flow that
Conditional Access commonly blocks), probe Graph readability, and fall
back to interactive sign-in with explicit scopes otherwise. The
AppRoleAssignment.ReadWrite.All scope guard now applies only to the
interactive path, since ambient-identity tokens carry a fixed scope set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Windows 11 24H2/25H2 (observed on builds 26200 and 26220, session
161b838c) the MS_Power_Management_Event_Provider is still registered in the
WMI repository but activating the extrinsic push query fails with
WBEM_E_NOT_FOUND - the legacy provider no longer loads on exactly the OS
builds new devices enroll with, so the power watcher degraded fleet-wide.
Arm chain: try the push event first (still alive on older builds), then
fall back to the intrinsic __InstanceModificationEvent WITHIN 30 on
Win32_Battery. The WITHIN sampling runs inside WinMgmt, not in the agent;
the handler path is unchanged (payload ignored, re-probe + diff), worst-case
detection latency ~35s incl. debounce. collector_degraded now fires only
when BOTH attempts fail; the arm log line names the active mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Event-driven AC/battery observation during enrollment (no polling): a WMI
Win32_PowerManagementEvent (EventCode 10) push subscription re-probes
GetSystemPowerStatus and diffs snapshots through a pure tracker into
power_state_change events - transition ac_to_battery (Warning, immediate) /
battery_to_ac (Info) / threshold_crossed at 50/30/15% (per-level lifetime
latch, lowest-crossed-only on jumps, arm-time baseline covers sessions
starting low, 5s trailing-edge debounce, emission cap 20). Never armed on
devices without a battery; arm failure surfaces as collector_degraded.
Kill-switch Collectors.EnablePowerStateWatcher (ConfigVersion 38).
power_state_change is classified non-activity on both sides
(SignalActivityClassifier + IsPeriodicOrStallEvent) so battery drain can
neither keep idle collectors alive nor heal a Stalled session.
New analyze rules, both with on_event:power_state_change interim triggers
and deliberately single-required-condition (event_data conditions scan
events independently - no same-instance join):
- ANALYZE-DEV-009 (high): thresholdPercent equals 15
- ANALYZE-DEV-010 (warning): transition equals ac_to_battery
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The JSON editors serialize the FORM object, but what users paste is usually a
rule EXPORT (e.g. received from another tenant). The create/edit whitelists
rebuilt collector parameters and evaluateOn from form fields the export does
not carry, so a pasted gather rule silently lost every collector parameter and
a pasted analyze rule silently reverted to the enrollment-end default.
Both rules pages get a single shape-tolerant mapper in types.ts:
- gatherRuleToForm unflattens the nested parameters object (authoritative when
present, so edit-merge leftovers cannot leak), maps nullable rule fields to
form defaults, imports a missing emitMode as 'always' (the rule's actual
behavior) instead of the empty-form on_change default, and now also backs
startEditing so edit and paste share one mapping.
- analyze jsonToForm maps evaluateOn onto the eval* checkboxes (an explicit
evaluateOn wins over stale form flags in edit-mode merges); terminal-only
exports fall back to the enrollment-end default.
- tags and the folded effective markSessionAsFailed now survive the import:
both forms carry tags into the create/save payloads, analyze creates carry
markSessionAsFailedDefault. notify/notifyChannelIds stay excluded — channel
ids are tenant-specific.
9 new vitest cases pin both shapes, the merge precedence, and the round-trips;
full web suite 824/824, tsc and eslint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Custom rules are tenant-partitioned and functionally collision-free, but three
surfaces still keyed on the bare non-unique ruleId string:
- RuleStats global aggregate (global_{ruleId}) summed same-ID custom rules of
unrelated tenants (title/severity last-writer-wins). The global row is now
catalog-only: analyze gates on IsBuiltIn/IsCommunity, gather gates on the
reserved-namespace pattern (agent events carry no flag; custom rules can
never occupy the reserved pattern).
- The 'never ship a built-in in the CUSTOM namespace' commitment from
RuleIdPolicy was documented but unenforced: combine.js now fails on
duplicate or non-reserved built-in IDs, BuiltInRuleCatalogPolicyTests pins
the same invariants on the embedded catalog, and the GitHub reseed rejects
non-reserved IDs before writing to the global partition.
- DeleteRuleStatesForRuleIdAcrossTenantsAsync (cross-partition delete by bare
RowKey) now refuses IDs outside the reserved namespace with failed=-1 so
callers abort the follow-up global catalog delete.
Verified: prod global partitions (45 analyze + 11 gather) are fully inside the
reserved namespace, so the guards touch no existing data. New OKF doc
docs/rules/rule-id-namespace.md captures the identity/collision model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The async queue correlation stored the event with Sequence 0, sorting it to
the top of the timeline (and tripping the time-jump badge) even though it
arrives last. Follow the BuildSessionTimeoutEvent/BuildAgentEmergencyBreakEvent
pattern: max+1 so it sorts last; WhiteGlove Part 1 anchors at the Part-1
terminal markers so a late device_setup report can never cross the web
timeline's sequence-based Part-1/Part-2 split. Also upsert the EventType
index the queue path never wrote, making the event visible to cross-session
search.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The IME processes the ESP app list in multiple passes (device-ESP evaluation
ending Skipped, real install up to an hour later), and AppInstallSummary
folded first observation -> last terminal across them: a 23-second app late
in the queue reported 4002 s (session 44862cd3, "Set TimeZone").
Backend ingest: new max-folded LastAttemptStartedAt anchors DurationSeconds
(CompletedAt - last attempt start; legacy rows without the anchor keep the
span fallback), replay-guarded additive InstallPassCount, attempt-scoped
DownloadDurationSeconds pairing, and a weaker-terminal guard (completed with
state Skipped/Postponed never overrides Installed/Error, in-batch and in the
cross-batch reconcile). StartedAt stays the earliest-wins window/version-
ordering key. AppsDashboardProjection carries the new columns.
AppVersionRegressionRadar: cutover gate — only attempt-anchored rows enter
the duration population on BOTH comparison sides, so mixed-semantics medians
can neither mask real regressions nor falsely re-arm active episodes.
Time attribution: per-pass segment pairing replaces the first-start ->
last-terminal hull. An app's seconds = sum of its ACTIVE segments (failed
attempts and evaluation passes count; inter-pass idle gaps don't), occupancy
merges the segments, hull endpoints remain for display and what-if bounds.
Agent: a terminal->active transition re-arms BOTH timing endpoints, so event
payload durationSeconds also describes the attempt.
Web/PDF/MCP: labels and tooltips say "install time (final attempt)", the
apps sessions table gets a xN multi-pass chip, the time-series chart carries
a cutover footnote, MCP tool descriptions document the semantics split
between get_app_install_metrics and get_time_attribution.
No backfill: pre-cutover rows keep span values (documented in the public
changelog). OKF doc: docs/backend/app-install-duration-attempt.md.
Suites: Functions 4383, Agent 2407, MCP 564, Web 814 - all green, build 0/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enrollment durations are heavily right-skewed; a handful of overnight/WhiteGlove
outliers made the Fleet Health and Dashboard average cards read like hours
(541 min) and answer no real question. Both cards now lead with the median
(robust 'typical enrollment' answer) and keep the tail visible via a P90
subtext. AvgDurationMinutes stays in both DTOs for API compatibility.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bbc7a1d8 stamped LastIngestAt into IncrementSessionEventCountAsync's merge
entity intending it as Sessions-only ("deliberately not mirrored"), but that
same entity flows through SyncSessionIndexAsync into the index merge — the
field was already landing in SessionsIndex on every ingest batch, just
undeclared. The fail-soft drift guard has been warning ~1,200x/h in prod
since the 08-20 deploy, and a StartedAt-shift full rebuild would silently
drop the column, making index-served session reads lose lastIngestAt that
Sessions-served reads carry.
Declare it properly instead of suppressing: ConditionallyProjected in
SessionIndexFieldManifest, projected in BuildSessionIndexEntity (read side
MapToSessionSummary already handled it), sample value pinned in the
bidirectional manifest tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 60-min RealmJoinTimeout deadline arms at RJ detection (the RJ agent MSI
install during DeviceSetup) and used to cut off monitoring mid-install: report
55e6afd61c9d (session 46a96078) lost the tail of a large package catalog whose
last observed package completed 3 seconds before the deadline, with the first
deployment only starting 16 minutes into the window.
When the deadline now fires with LastDeploymentPhase 100/101 and deployment
activity (phase change or package observation, persisted restart-safe in
RealmJoinFacts.LastActivityUtc; the detection-time phase capture deliberately
does not count) inside the last 60 min, the handler re-arms to
lastActivity + 60 min instead of timing out — Info event
realmjoin_timeout_extended — clamped at an absolute cap of detection + 4 h.
realmjoin_timeout carries a reason payload (hard_timeout / inactivity /
absolute_cap); a fire of a deadline incarnation superseded by a re-arm
dead-ends via the OccurredAtUtc == DueAtUtc scheduler contract. The idle case
(no activity ever) still times out at 60 min, and phase-200/210 activity never
extends. Packages are only registry-visible at completion, so the inactivity
window keeps the full original 60 min.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent stamps X-Send-Time-Utc (device clock, ISO-8601 round-trip) per upload
ATTEMPT; ingest parses it (year>=2000 guard, deliberately no upper clamp) and
server-stamps SentAt onto every event of the request. SentAt - OccurredUtc is
pure spool delay, ReceivedAt - SentAt is network + device-vs-server clock
offset - previously indistinguishable and only separable via median statistics.
clock_skew condition: batches carrying SentAt measure the frame directly
(spool-immune by construction - no spread cap, IME/clamped exclusions
irrelevant, IME-only sessions become measurable; evidence sentAtBatchCount);
legacy batches keep the per-event median path unchanged. CmTraceSkewTripwire
deliberately untouched: its differential median math cancels clock offset as
common mode.
Full persistence chain: Store x2 + Map, fields= projection (sentAt), MCP
get_session_events field list + rule-authoring-guide. New OKF doc
backend/send-time-frame-separation.md.
Suites: Functions 4370 (+16), Agent 2407 (+1), MCP vitest 564 + tsc 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The labeled provenance rows duplicated values already visible in the
raw JSON dump and inflated the metadata block for every IME event. The
section is now collapsed by default behind a small "time provenance"
toggle sitting inline on the Created row (whose value it explains) -
zero extra height when collapsed, and the base label column returns to
its original width, so the default detail view is pixel-identical to
before P13. Expanding reveals the same rows, indented under Created.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-line self-anchoring provenance (sourceLocalTs, applied offset +
origin, observational writer offset, derived/rejected markers) reaches
the browser but was buried unlabeled in the raw JSON dump. Sequence
stays THE sort order - without exception (now pinned by a regression
test); the corrected UTC is display-only, and a backwards step of the
displayed time within sequence order is marked as information instead
of confusing the reader.
- lib/timeProvenance.ts: readTimeProvenance (camelCase/snake_case,
tolerant of truncated payloads) + classifyTimeJump with cause
precedence derived > rejected-source > era-offset; era-offset
requires applied offsets on BOTH rows - it never guesses.
- TimeJumpBadge on the row AFTER the jump, computed against the
previous RENDERED row (filters/phase boundaries respected); sky for
known causes, amber otherwise; tooltip carries both times, the delta
and the "true write order (sequence)" sentence. Threshold 5 min,
field-calibrated on 20 sessions: 12/12 normal sessions show zero
badges, the era-mixed regression session shows exactly its 6.
- Clamped chip (rare) + TIME PROVENANCE detail section: only the
applied offset says "(applied)"; the writer offset is explicitly
observational (sticky after era flip-backs); origin chips with
forward-compatible fallback for unknown values.
- EnrollmentEvent gains timestampClamped/originalTimestamp (wire
already carried them); formatUtcOffset joins lib/formatting.
Verified: 814 web tests (19 new), tsc/eslint/build clean; Playwright
against e9753578 (badges match the pre-measurement), 9f475bb2 (no
badge on line-anchored rows, detail section correct, raw mode shows
exactly the one predicted jump).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator side: CmTraceSkewTripwire piggybacks on the terminal counter
reconcile's partition scan (zero extra reads) and compares median
ReceivedAt-OccurredUtc deltas of IME-derived vs other events. A clean
15-min-grid divergence (residual < 2 min, >=20 samples / >=3 distinct
batches per side) emits the CmTraceTimeSkewRegression ops event
(Agent/Warning, goal: never fires). Bias-dominated sessions are
suppressed; measuredWriterOffsetMinutes is never consulted (sticky
after era flip-backs). Kill switch: CmTraceSkewTripwireDisabled.
Customer side: new clock_skew analyze condition source (skewMetric
clock_jump | sustained_offset, threshold seconds, gt/gte magnitude)
behind built-in ANALYZE-DEV-008 (300 s = the Kerberos/Entra 5-min skew
boundary). Batch-median frames with spool-spread filter, plateau step
detection (a gradual backlog-drain ramp cannot fake a jump) and
end-state persistence; IME-derived events are excluded so a CMTrace
anchoring regression can never fire the customer rule. Evidence carries
field=skewSummary/value for {{skewSummary}} interpolation.
Full mirroring chain: schema enum + skewMetric property, shared
manifest + combine.js regeneration, MCP authoring guide + semantic
lint, web SOURCES/form/card, ops-event dual-register, docs. P5 is
subsumed by the tripwire. PercentileMath is now the single nearest-rank
implementation (Apps/PlatformMetrics delegate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field forensics from the 1412 verification run (session 067797d8): the
label/anchor crossing recurred on a provably genuine binary, and the
new anchor-timestamp tripwire delivered — the "+02:00 measured for
IntuneManagementExtension.log" anchor (local 19:51:26.948) does not
exist in ANY log file on the device, and IME.log never produced its
expected first measurement during 24 s of growing passes beforehand.
Mechanism still unexplained; per-line anchoring keeps resolution
immune (all 58 line-anchored events of the session were correct), but
the observational layer must convict the culprit next time:
- CalibrateFrom now receives the iteration's ACTUAL file name next to
the calibration key. A mismatch — the smoking gun the two field
occurrences imply but static analysis says is impossible — logs a
Warning with both names plus the anchor, and refuses the anchor, so
even the unexplained path can no longer poison the measurement store.
- The first calibrator rejection per file logs at Debug (anchor local
time + clock). The 24 s of silent non-measurement is exactly what
made the phenomenon undiagnosable from the agent log.
232 IME tracker tests green against a verified-clean build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Why the reader-zone assumption and the per-file offset application both
failed, the per-line self-anchoring rule with its strict freshness
contract (LastCheckedUtc on every poll look, 30 s window, no freshness
across restarts), the provenance vocabulary that keeps every event
re-derivable, and the documented limits: the bounded grid-replay edge,
snapshot readers staying on the marked fallback, and the still-open
e9753578 label/anchor crossing with its tripwire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IntegrityCheckFailed emergency reports used to land in App Insights
only — session e9753578's agent reported a hash mismatch and no product
surface showed it, which cost a day of mis-attributed root-causing.
They now materialize as an AgentBinaryIntegrityMismatch ops event
(Warning, Agent category, dual-registered in the web alert-rule
catalog per feedback_ops_event_types_dual_register).
With suppression for the KNOWN, accepted release race the operator
correctly called out: around every release, agents compare against
hash snapshots that do not belong to their own binary — an older agent
sees the new release's hash on its next config fetch, and a freshly
installed latest agent can receive the PREVIOUS release's hashes from
the 5-minute AdminConfiguration cache (e9753578: genuine 1410 exe
checked against the cached 1409 hash — proven by the 1409 archive
logging "verified OK" with exactly that hash). Both self-heal via the
forced self-update and are suppressed at materialization time against
the CURRENT config: reported running hash == published exe, or
reported version != latest. What remains — an agent claiming the
latest version whose binary is NOT the published one — is the real
signal: tamper, stale blob, or a build outside the release pipeline.
Missing config fails open to recording; visibility beats suppression.
Ops event only, deliberately no timeline event: the report is about
the binary, not the enrollment, and may precede session registration.
Backend suite 4320 green, web tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The era-aware successor to the reverted per-file offset application
(04b1a7c6). At a 100 ms poll, every line of a provably fresh pass was
written essentially "now", so its own distance to the agent clock,
rounded to the 15-minute offset grid, IS its writer's UTC offset — no
cross-line state, which is what interleaved writer eras break. The line
timestamp still contributes sub-poll precision and ordering; the grid
absorbs poll latency. Origin: LineAnchored → "line-anchored".
Freshness is stricter than "the file grew": every poll look stamps
LastCheckedUtc (empty first sights included, so AgentExecutor.log
anchors from its very first content), a pass anchors only when the gap
since the last look is ≤ 30 s (FreshLineMaxAge — far above the poll,
far below the 13-minute zone where a line's AGE rounds onto the grid),
and a restart-restored bookmark deliberately carries no freshness at
all — downtime backlog must never anchor. Everything not provably
fresh keeps the uniform reader-zone fallback: wrong in absolute terms,
self-consistent for durations, and marked as such (the revert's core
lesson — partially corrected is strictly worse than uniformly wrong).
Accepted, bounded edge (characterization test): a freshly WRITTEN line
carrying a replayed timestamp aged exactly N×15 min ± 2 min anchors to
≈now — an anchored line lands at now ± 2 min by construction, and
sub-24h replays are treated as current by the historic-replay guard.
The per-file measurement stays observational (Info log +
measuredWriterOffsetMinutes) and now logs the anchor's local timestamp
as a tripwire: session e9753578 logged "+02:00 measured for
IntuneManagementExtension.log" although that file provably held no
+2-era line — a label/anchor pairing the committed code could not be
shown to produce (iteration-local anchor, single sequential call site,
green repro of the exact field scenario, no +2-capable chunk in the
window). The mechanism remains open; the resolution path is immune to
it by design, and CmTraceCalibratorFileIsolationTests pins the file
isolation so a real regression can never ship silently.
Tests: 13 new — resolves-to-T across five writer beliefs, interleaved
eras inside one chunk, first-sight/empty-first-sight/poll-gap/restart
freshness boundaries (including the nastiest backlog: exactly 30 min
old, on-grid), the grid-replay edge, and the committed two-writer-era
fixture replayed at production poll cadence across its write silences.
Agent suite 2406 green against a verified-clean build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two corrections to 3c25172d, both found by asking why the repo suddenly had two
fixture locations.
Location: committed test data lives under tests/fixtures/. I had put this one
inside the agent test project without checking, creating a second parallel home.
It does NOT belong in tests/fixtures/enrollment-sessions/ either — that is a
governed corpus of anonymized DecisionSignal JSONL for the Replay Harness, with a
schema, category prefixes and a paired-scenario-test rule that raw log text cannot
satisfy. So: a sibling corpus, tests/fixtures/cmtrace-logs/, with its own README
stating why it is separate.
Anonymization: the fixture carried three real Intune policy/script GUIDs from a
customer tenant. Point 1 of the existing fixture policy requires tenant-scoped
identifiers to be replaced, and I committed without applying it — the check
belonged BEFORE the commit, not after. Replaced with
policy-anon-NNNN-0000-0000-0000-000000000000, consistent with the tenant-anon /
device-anon / user-anon convention already in use. The all-zero GUID is a literal
in IME's own output and stays; the session ID stays as our diagnostic anchor and
is already referenced from the commit history and tasks/todo.md.
The new README carries the commit policy for this corpus, including the rules that
were implicit before: verbatim content (the parser is tested against real
formatting), a header stating what the fixture pins, versioned filenames so a
pinned test cannot silently change meaning, and .cmtrace rather than .log because
*.log is globally gitignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Captured from ImeLogs/AgentExecutor.log of the session that exposed the regression
(agent 2.0.1410; device booted on the Pacific OOBE default, agent set W. Europe at
12:13:22Z).
One file, two writer eras: 371 lines rendered at local hour 05 by a writer still
on PDT (UTC-7) and 195 at hour 14 by one already on CEST (UTC+2) - nine hours
apart, interleaved, and no line carries a bias suffix, so nothing in the file
itself says which is which. Exactly the case the per-file calibration could not
express, and the test that was missing.
Analysing the archive also turned up a SECOND, independent defect the revert
commit did not know about: the agent log reports "IntuneManagementExtension.log
writer offset measured 02:00:00" while that file contains ONLY hour-05 lines
(2022 of them, zero at hour 14). A +2 measurement is impossible from its own
content, so the calibration anchor leaked across file boundaries - files are
walked in sorted order and AgentExecutor.log precedes it. Two-eras-per-file and
anchor-leak are separate bugs; both must be fixed.
Extension is .cmtrace.txt, not .log: *.log is globally gitignored, and a fixture
has no reason to claim that extension.
Fixture only, nothing consumes it yet - the era-aware calibrator is next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Field regression, session e9753578 (agent 2.0.1410, PST-defaulted device in DE).
Five script_started events resolved 9 h early while their script_completed
counterparts resolved correctly, inflating every platform-script duration to
~32,400 s. Reverts the application introduced in e9dba11b.
Root cause: calibration keeps ONE offset per log file, but a single file can hold
lines from two writer eras. IME restarting across a timezone change leaves older
lines in the old zone and newer ones in the new zone, in the same file. The anchor
is the newest line, so its offset was also applied to the older lines, for which
it does not hold. The class doc even claimed "self-healing on writer restart" —
that reasoning only ever covered FUTURE lines, never the ones already on disk.
The more expensive mistake was the second one: correcting ABSOLUTE timestamps
without checking what depends on their DIFFERENCES. Applying the reader zone
uniformly is wrong in absolute terms but self-consistent — both ends of a duration
are wrong by the same amount, so the duration is right. Correcting only some
timestamps produced a mixed frame inside one session, which is strictly worse than
being uniformly wrong. This is the same asymmetry the original analysis described
for AppInstallSummaries (68 min reported against 8 min real) and then reproduced.
What stays, deliberately:
- Bias-carrying lines still resolve via the writer-declared offset; that path was
never implicated.
- The measurement keeps running. It costs nothing, feeds the Info log, and is the
field data the era-aware fix has to be built on — the first anlauf got the era
question wrong from assumption rather than observation.
- Provenance now separates measured from applied (measuredWriterOffsetMinutes vs
sourceOffsetMinutes). The broken event was tagged "calibrated" while carrying an
offset that did not hold for its line; that confusion must not be expressible.
- The fallback path reports the offset it actually applied. An event stating no
offset cannot be recomputed later, which defeats the point of provenance.
Provenance earned its keep here: 57 events measured -420 (the PDT writer) against
5 at +120 made the diagnosis a two-minute query instead of median statistics over
a session.
Next: era-aware calibration keyed on the IME-STARTED marker, which we already
track, plus a test over a log file containing two writer eras — the case that was
missing.
Agent 2393 green against a verified-clean build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every IME-sourced event now states what the writer wrote, what offset was applied,
and how that offset was obtained:
sourceLocalTs raw local time, verbatim, NO zone suffix
sourceOffsetMinutes the offset subtracted
sourceOffsetOrigin bias | calibrated | reader-zone-fallback
Why: until now the only record of a conversion was the converted value, so a wrong
offset was indistinguishable from a right one. Proving the field defect needed the
diagnostics archive — and of the 5,386 sessions that went through a timezone
change, essentially none had one, so the analysis had to fall back to median
statistics across thousands of sessions to establish what one stored value makes
obvious per session. It also makes a stored timestamp recomputable after the fact,
without an agent release.
No zone suffix on sourceLocalTs on purpose: the value genuinely carries none, and
formatting one in would re-introduce the assumption this whole change removes.
sourceOffsetMinutes is normalised to one sense (local = UTC + offset) across both
origins. The wire bias uses the opposite GetTimeZoneInformation convention
(UTC = local + bias), so a "+480" line is reported as -480; without that the field
would flip sign depending on where the offset came from and be useless to compare.
Added centrally in TagDerivedTimestamp, which all 14 emit sites already call, so
this does not scatter through the adapter. DataJson only — no table schema change,
so no Store/Map/seed chain and no backend deploy needed to start collecting it.
Agent 2393 / backend 4313 green against a verified-clean build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ResolveOccurredAt rejected a source timestamp when ageHours < -1.0. That bound
could not fire for the single most common timezone error: with a one-hour offset
mistake the real value is parseLag - 3600 s, i.e. just ABOVE -1.0. It sat exactly
on the boundary of the case it was meant to catch, which is why every skewed
session sailed past it.
It was asymmetric too — a past-shifted error had to exceed 24 h before anything
noticed, while the measured field errors reached -17 h.
The bound is now derived rather than guessed: writer and reader share one system
clock, and the tracker only sees a line after it was written, so a correctly
converted source timestamp is always at or behind "now". Anything ahead means the
conversion was wrong. MaxFutureSourceSkew is 2 minutes of jitter tolerance —
deliberately far below the hour a timezone mistake produces.
The rejection log now names which bound tripped and by how much, instead of
printing a bare ageHours that reads the same for both directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the behaviour change the previous two commits prepared. ImeLogTracker no
longer substitutes its own timezone belief for the IME process's; it measures the
writer's offset from a freshly appended line and converts with that.
Freshness is stricter than "the file grew". GetSafePosition returns 0 for an
unknown file, so the first pass reads the whole file and its newest line may be
hours old — anchoring there would measure the line's age, not the writer's offset.
LogFilePositionTracker.HasSeen(), captured BEFORE GetSafePosition creates the
entry, gates calibration on growth against a size we had already observed.
Calibration runs at the END of a qualifying pass, so that pass still uses the
previously measured offset — at most one poll (100 ms) old, and an offset only
moves when a process restarts. Calibrating first would mean buffering the pass,
which is not viable: the first pass over AppWorkload.log can be hundreds of MB.
The cost is a warm-up of one growing pass, during which lines fall back to the
reader zone.
Bias-carrying lines are never used as anchors: they already state the writer's
offset, so they need no measurement and must not overwrite one.
CalibrateFrom logs at Info when the measured offset disagrees with this process's
own zone, naming the correction applied. That disagreement is precisely the
condition that silently corrupted every IME-derived timestamp until now; agreement
stays at Debug so the normal case does not add noise.
Tests are deliberately machine-independent: the assertion is always "a line
written at instant T resolves to T", asserted across writer beliefs of +2, +1, 0,
-7 and +10. That one property covers both field cases — the writer disagreeing
with us (the bug) and the writer agreeing with us (the majority of sessions, where
two stale beliefs cancel and which a fix to the agent's own cache would have
broken). Plus: no calibration on first sight, following a writer restart into a
new zone, and a declared bias honoured without being measured.
CheckLogFilesAsync is internal as a test seam — calibration depends on the pass
structure, which only a real read cycle exercises.
Agent 2390 / backend 4313 green against a verified-clean build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Behaviour-neutral: 2381 agent and 4313 backend tests pass unchanged.
The parser resolved a bias-less line with DateTimeStyles.AssumeLocal followed by
ToUniversalTime(), i.e. with the READING process's cached zone. That silently
assumes reader and writer agree. They do not — each caches its zone at process
start and neither follows a later tzutil or Windows auto-timezone change — and the
assumption was buried inside a shared helper where no call site could see it.
CmTraceLogEntry now exposes what the line actually contains: LocalTimestamp
(Kind=Unspecified, because the value is meaningless without knowing the writer's
zone) and BiasMinutes. TimestampUtc is populated only when the writer declared its
bias, which is the one case where the offset is genuinely known. Timestamp is
removed rather than deprecated, so the compiler names every site that has to
decide — it immediately turned up ProcessLogMessageForTest, which grep had missed.
The defective path still exists, but now as one named function,
ResolveUtcAssumingReaderZone(), whose documentation states when it is wrong. All
three consumers call it and so keep their current behaviour exactly:
- ImeLogTracker routes every use through ResolveEntryUtc(), the single point where
the measured offset replaces the fallback in the follow-up change;
- StallProbeCollector and LogParserCollector read file snapshots rather than
tailing, so they cannot measure an offset and stay on the fallback for now.
The parser also no longer invents a timestamp. An unparseable time used to be
stamped DateTime.UtcNow inside the parser, making a fabricated value
indistinguishable from a real one downstream; it now reports HasTimestamp=false
and leaves the fallback to the caller, which alone knows what is safe in context
(StallProbeCollector still treats such a line as fresh — calling it stale would
hide progress).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A CMTrace line carries local time and no offset — IME 1.104 writes
DateTime.Now.TimeOfDay in both of its trace listeners. Converting it with the
agent's own TimeZoneInfo.Local assumes both processes agree on the zone. They do
not: each caches its zone at process start and neither follows a later tzutil or a
Windows auto-timezone change, so the error is Offset_writer - Offset_agent and is
zero only by coincidence.
Measured over 11,068 sessions: 26 carry a real zone-offset error, +1 h (GMT to
W. Europe, session bfff9b1c) through -17 h (a writer still on the Pacific OOBE
default while the agent had already moved to E. Australia). Note the sign — this
is why "make the agent's own clock honest" is the wrong fix: in the -17 h cases
the agent was correct and the writer was stale, and clearing the agent's cache
would have broken the majority of sessions where both are stale and the two errors
cancel.
The tracker tails at a 100 ms poll, so a line that appeared since the previous
pass was written essentially "now". That makes the writer's offset measurable:
round(lineLocal - agentUtcNow) onto the 15-minute grid every real UTC offset sits
on. Per source file, because IntuneManagementExtension.log and AppWorkload.log are
written by different processes; re-measured on every fresh anchor, so a writer
restart is picked up within one poll.
The freshness requirement is the load-bearing part and cannot be replaced by the
residual guard: a backlog line exactly 30 minutes old is itself a clean multiple
of the grid, rounds with zero residual, and would calibrate the offset half an
hour wrong. RejectsBacklogLine_EvenWhenItRoundsCleanly pins that the calibrator
cannot detect this on its own — only the caller's freshness rule prevents it.
Additive only; no call site uses it yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sequence is a persisted, strictly monotonic per-session counter assigned at emit
time (EventSequenceCounter: locked, saved before return, survives restarts). It is
immune to every clock problem. Timestamp carries the device clock frame plus, for
CMTrace-derived events, the writing process's timezone belief.
EvaluateAppInstallDurationCondition and EvaluateEventCorrelationCondition both
ordered by Timestamp and expressed "A happened before B" as a timestamp
comparison. Both now order by Sequence and compare Sequence for ordering.
Timestamp deliberately stays where an actual ELAPSED DURATION is meant:
durationSeconds, the evidence fields, and TimeWindowSeconds.
Scope, honestly: this is hardening, not a live bug fix. BuiltInAnalyzeRules.cs
contains zero event_correlation conditions, so the cross-frame join can only be
reached by a self-authored rule. And the app-install duration evaluator pairs
app_install_started with app_install_completed — both IME-sourced, one frame — so
its arithmetic was already correct. (The measured 68-vs-8-minute inflation comes
from EventIngestProcessor.AppInstall, which folds DO-collector download_progress
together with IME app_install_completed; that is fixed at the root in the agent.)
The TimeWindowSeconds check stays exposed: the window IS a duration, so there is
no alternative to a timestamp there. A +1 h skew on B makes any window below
3600 s fail to match. Only the agent-side calibration closes that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LastEventAt is the maximum AGENT-supplied event timestamp and therefore lives in
the device clock frame. The silence sweep compared it against a server-derived
cutoff, mixing two frames.
Measured over 11,068 sessions: 26 carry a genuine CMTrace timezone skew on their
IME-derived events, from -17 h to +1 h (a further 27 carry plain device-clock
offsets). At -17 h a live agent looks silent the moment it starts and is marked
Stalled with a customer-visible "Agent silent for 1020min"; at +1 h the sweep is
blind for an hour.
Adds SessionSummary.LastIngestAt, stamped from the server clock in
IncrementSessionEventCountAsync — reaching that method IS contact from the agent,
so it is written unconditionally rather than gated on latestEventTimestamp.
The sweep's server-side filter now spans both frames (LastIngestAt lt X or
LastEventAt lt X): filtering on LastIngestAt alone would silently drop every row
written before the field existed, because Table Storage does not match a filter
against a missing property. The authoritative decision moves into
ServerFrameLastContact() (LastIngestAt ?? LastEventAt ?? StartedAt) and is applied
in both stages — before the Stalled marker and in the stage-2 alive guard.
Deliberately not mirrored into SessionsIndex: the sweep reads Sessions, and the
index manifest is pinned bidirectionally by tests.
shared-manifests.json + .generated.ts regenerated — SharedManifestParityTests is
the drift brake and correctly went red on the new field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section headings now reserve keep-with-next space (charts reserve their full
height), so a title never strands at a page bottom while its table starts on
the next page. Table cells wrap up to 4 lines instead of single-line ellipsis,
and the exit-code column carries the full error description (the 24-char slice
is gone) - that text is what the app owner acts on. Failure-code column widths
rebalanced (Code 42 / Description 62 / Exit 60 / Count 16).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pipeline's AutomaticDecompression (GZip|Deflate) removes the response
Content-Length header on virtually every backend JSON response, so
net_total_bytes_down in agent_metrics_snapshot read 0 on healthy sessions.
When the header is absent, buffer the content (LoadIntoBufferAsync, 16 MB cap)
and record the DECOMPRESSED payload size — HttpClient buffers responses anyway
(default ResponseContentRead), so no extra memory cost. Fail-soft: a buffering
error keeps 0 and leaves the failure for the caller to observe.
AutomaticDecompression itself stays untouched (pinned by
MtlsHttpClientFactoryTests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IME 1.104 removed the [DO TEL] log line, leaving the agent's DO collector as
the only do_telemetry source — and its gate only fires when a 3s poll itself
observes the completed download. Fast, interrupted or between-poll-completed
downloads never emit one, so DoDownloadMode stayed -1 and DoAggregator dropped
the whole row: DO bytes read as "practically null" on 1.104 fleets even though
download_progress events carried the full do* set on every poll.
- Shared ApplyDoFields for both writers: download_progress folds byte counters
and file size via Math.Max (monotonic, replay-idempotent) and sets the mode
only when >= 0; do_telemetry stays authoritative (last-write-wins) but never
regresses DoDownloadMode to the -1 unset sentinel.
- DownloadBytes now prefers actually-transferred bytes over doFileSize (the
file size used to inflate the transfer measure).
Intended effect after deploy: DO aggregates count previously-invisible
incomplete downloads, so app counts/bytes rise. No backfill of historic rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New GA-only tool get_agent_efficiency_metrics: near-pass-through of the
server-side per-version aggregation (120s timeout, default 500 sessions,
optional tenantId). Added to GA_FULL + PLATFORM_ONLY catalog snapshots.
- platformWindowEcho decides truncated on the backend's sessionsScanned
(sessions actually covered) and only falls back to the has-snapshots count
for old backends — the previous comparison read false even when the cap was
hit exactly, whenever some scanned sessions carried no snapshots.
- get_platform_metrics: 90s timeout (backend fans out one storage query per
session); spool block, crashRate, deliveryLatency, computedAt,
computeDurationMs and fromCache are no longer dropped on the MCP side;
description clarifies totalBytesDown is the agent's own HTTP traffic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New GET /api/global/metrics/agent-efficiency (GlobalReadOrAdmin, ?tenantId=):
per-agent-version percentiles (CPU, working set, private bytes, threads,
handles, spool, API latency/requests), crash rates per version, top offender
sessions per dimension, 5-min cache keyed (days, limit, tenant). First
backend consumption of agent_thread_count/agent_handle_count.
- PlatformAgentMetricsResponse.SessionsScanned: sessions the scan actually
covered — the honest basis for the caller's truncated flag (Sessions.Count
only counts sessions WITH snapshots).
- Per-session fetch no longer drains the full Events partition: new
GetSessionEventsByTypesAsync (multi-type OData filter + column projection +
real maxResults enforcement); the 20 delivery-latency sample sessions keep
the full fetch so latency deltas stay computed over all events.
- Shared AgentMetricsAggregation (crash tally, payload getters, bounded
fan-out, metrics event types) so the two services cannot drift.
- GetAllSessionsPageAsync gains an optional select projection (default null,
existing callers unchanged), pinned by an equivalence test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an "Export PDF" button to the app detail page that renders the full
90/30/7-day install report (KPIs, both charts, failure codes, device model
correlation, version breakdown, detection-rule/regression callouts) as a
branded A4 PDF, drawn data-driven with jsPDF — no DOM capture, so the output
is light-palette and theme-independent. jsPDF stays out of the route bundle
via on-click dynamic import (verified: separate async chunk).
Sample messages and the affected-sessions list are deliberately excluded:
the PDF is meant to be emailed outside the org.
Tracks app_report_pdf_exported in Application Insights (windowDays, scope,
durationMs, pageCount, success/errorName — chunk-load failures included).
Generic chrome (header/footer/tables/KPI cells/callouts) lives in
utils/pdf/pdfPrimitives.ts for reuse by future report exports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five stuck sits-d Cloud PCs carried SkipUserStatusPage=True — verified
across three agent processes (10+ registry reads, True every time) and six
hours of live Shell-Core watching with zero 62407: Windows never rendered a
user ESP page, so no exit and no AccountSetup provisioning registry can ever
exist on this flow. Arm B of ShouldTransitionToAwaitingHello was open from
the bootstrap EspConfigDetected onward, but every evaluation site hangs off
a signal the flow cannot produce, and the AdvisoryCompletion backstop arms
exclusively from an EspExiting signal — an open gate nobody knocks on, six
hours to the max-lifetime watchdog. The earlier lost-exit-through-reboot
diagnosis is retired by the same evidence.
HandleImeUserSessionCompletedV1's completion attempt now accepts the
observed skip in place of IsPostAccountSetupFinalExit (anchor + genuine IME
user-session completion + real-user desktop stay mandatory; Device
Preparation excluded). The knock deliberately lives on the IME edge, not on
desktop arrival: Fix 10's AccountSetup bounce-back would undo a desktop-side
promotion two signals later, while the IME phase line always precedes the
user-session-complete line in log order, so a bounced promotion is
re-knocked by the very next re-emission — restart re-parse included.
Docs and comments no longer claim an ESP-exit recovery that does not exist
(stray duplicate summary in ShellCoreTracker, ImeLogHost restore-callback
comment); the OKF doc is reframed on the evidence and names the residual
SkipUser=false exit-in-downtime gap honestly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-four review, confirmed — and the cause sits a layer below where I had been
fixing. The IsBackfill return added last round only guarded the coordinator's own
completion logic. Upstream of it, ShellCoreTracker still raised
FinalizingSetupPhaseTriggered, the coordinator still forwarded EspExited to the
adapter before that return, and the adapter discards the args entirely
(OnEspExited(...) => EmitEspExiting()). So a replayed exit still arrived at the
reducer as an ordinary, unmarked EspExiting.
There it is decisive: HandleEspExitingV1 passes espFinalExitInFlight: true for
every arriving exit, and IsPostAccountSetupFinalExit orders exits by ingest
ordinal rather than timestamp — so a historic exit replayed today reads as
post-AccountSetup by construction. With state restored from the snapshot
(AccountSetup entered, a genuine IME user session, desktop arrived) arm C of
ShouldTransitionToAwaitingHello opens on the Device→Account handoff. The reducer
is itself a completion gate and cannot tell the two apart, so the fix has to be
upstream.
The replay is therefore narrowed to what is provably safe: the Hello-wizard start
(62404). That fact is conservative — it vetoes a premature "Hello is disabled"
skip and can never by itself complete a session, so replaying it can only make the
agent wait longer. It is also the observation session 772fe502 wrote the replay
for. A 62407 is no longer raised at all, neither exit nor failure: the exit
because Windows writes the identical description for the intermediate and the
final one and nothing downstream can order it, the failure because re-injecting it
as fresh can fail a session that recovered on retry (ANALYZE-ESP-006). Skipped
records are reported once as an agent_trace with counts and bounds — silently
dropping evidence is how this class of bug survives.
The cross-layer proof is now a pair, as the review asked:
ClassicEspExitingOnRestoredStateTests pins that the reducer opens on any arriving
exit (including one with an hour-old source timestamp), and
ShellCoreTrackerReplayScopeTests pins that the replay never produces one.
Dead machinery removed with it: EspExitedEventArgs.IsBackfill, allowEspExit, the
newest-exit selection, the IsBackfill return. The method is now called
BackfillRecentHelloWizardStart.
One pre-existing integration test drove EspExited through HandleBackfillRecord
because the replay is easier to call than the event-log watcher, and its class doc
claimed that was the production path — it never was, the replay had no caller when
that test was written. Switched to the live path and the doc corrected.
Agent V2.Core 2361, DecisionCore 538, Backend 4287, all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-three review, confirmed. The previous round stopped a replayed exit from
arming the DEFERRED re-check but left the immediate synthesis attempt right below
it untouched. My justification for that — "pre-existing behaviour" — was simply
wrong: the immediate attempt on a replayed exit never existed, because the
backfill had no caller. I introduced it, and by moving the replay behind the IME
state restore I made the dangerous state reachable by design: if the newest 62407
in the window is only the intermediate DeviceSetup→AccountSetup exit while the
restored app states are already terminal, that attempt opens the strong
AccountSetup gate before the final exit has even happened. A test was pinning the
faulty assumption as expected behaviour.
A replayed exit now returns before touching completion at all — no deferred edge,
no immediate attempt. Nothing available to it can establish its position in time:
Shell-Core writes the identical description for both exits, and every probe that
could order it reads state as it is now rather than at the event's time. The
replay keeps the job it was written for in session 772fe502 — feeding EspExiting,
FinalizingSetup and the Hello wizard to the reducer for the window in which no
agent process was observing. Completion is the exclusive business of exits this
agent watched live.
With the synthesis attempt gone, the replay no longer needs restored app states,
so it moves back into EspAndHelloHost.Start(); that also keeps this host's startup
independent of the IME host's. ImeLogHost.onStateRestored goes back to doing just
the re-check, which is still needed for a live exit observed before the restore
finishes.
The test asserting the faulty assumption is inverted, plus coverage that the
replay is not neutered (EspExiting still forwards) and that a live exit following
a replayed one completes normally.
Agent V2.Core 2364, DecisionCore 533, Backend 4287, all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-two review, both findings confirmed against the code.
A replayed exit was being confirmed as post-AccountSetup by reading the registry
at replay time. That read reflects NOW, not the event's time, so an agent that was
down across the DeviceSetup→AccountSetup transition would confirm the stale
intermediate exit and, once the apps settled, synthesise a completion while the
AccountSetup page was still up. Windows offers no way to tell the two apart from
the event itself — Shell-Core writes the identical description
CommercialOOBE_ESPProgress_Page_Exiting for both. So the deferred edge is now
armed by LIVE exits only (EspExitedEventArgs.IsBackfill): only for those is the
continuous-observation argument valid. A replayed exit keeps the single immediate
synthesis attempt it always had.
For that attempt to be worth anything it must see real data, so the replay moved
out of EspAndHelloHost.Start() into ReplayEspExitBackfill(), invoked from
ImeLogHost.onStateRestored — after LoadState() has restored the package states.
That is what makes "the agent came back long after the ESP finished" recoverable.
The five-minute window for non-reboot restarts was unsound: the scheduled task
carries a BootTrigger only, with no restart-on-failure, so after an
exception_crash the agent does not return until the next boot — hours later, long
after Windows wrote the final exit. The window is now measured rather than
guessed: back to the last moment the previous run is known to have been alive,
taking the wider of the snapshot mtime (rewritten every decision step, available
for every exit type) and LastBootUtc. The five-minute default is the floor,
ClampLookbackMinutes the ceiling, and a future timestamp from clock skew is
ignored instead of producing a negative window.
Also switched the backfill tests to the real Shell-Core wording — the invented
string matched the regex but hid the fact that both exits look identical.
9 new tests. Agent V2.Core 2362, DecisionCore 533, Backend 4287, all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the review hardening: the replay ran on EVERY agent start with a
5-minute window, first run included. On a first run there is no gap to recover —
no earlier agent process existed — and a 62407 already sitting in the Shell-Core
log belongs to a phase transition this agent was never meant to observe. Replaying
it pushes an EspExiting into the reducer that the pre-fix agent never saw, i.e. a
happy-path behaviour change for no gain.
ResolveEspExitBackfillLookbackMinutes now keys the window to the previous exit
type: first_run (or unknown) disables the replay entirely, clean/hard_kill/
exception_crash keep the 5-minute default because the process is back within
seconds, and reboot_kill widens to "since LastBootUtc" because that gap spans the
forced restart until the post-reboot logon. The result is still clamped to
BackfillLookbackMaxMinutes, so policy and backstop together cannot build an
unbounded event-log query.
With this the happy path is byte-identical to pre-fix behaviour: on a first run
nothing replays, no edge is remembered, and the deferred re-check returns on a
volatile bool read.
No other backfill is affected — the Hello, MDM-reboot, Windows-Update and
ModernDeployment trackers each own a private backfill invoked from their own
Start(), none of which this change touches.
13 policy tests including clock skew across the reboot, a missing boot timestamp
and the clamp backstop. Agent V2.Core 2353, DecisionCore 533, Backend 4287.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three real regressions, all confirmed against the code before touching it — two of
them introduced by the recovery itself.
The remembered exit edge was unqualified. Shell-Core raises 62407 at every ESP
phase transition, so OnEspExited also latched the DeviceSetup→AccountSetup one;
the deferred re-check would then open the strong AccountSetup gate as soon as the
last user app settled, while the AccountSetup page was still up and its other
subcategories still running. IsConfirmedPostAccountSetupExit() now demands
positive evidence (AccountSetup activity observed, or SkipUser == true) and is
deliberately stricter than the existing IsIntermediateDeviceEspExit() forward
guard: an unknown SkipUser counts as not confirmed. Erring strict costs the
pre-fix stall; erring loose costs a premature Succeeded. The immediate on-edge
synthesis keeps its previous behaviour verbatim.
The replay picked the oldest exit, not the final one. The reader walks
oldest-first and the exit branch is fire-once, so the widened downtime window —
which routinely holds both the intermediate and the final exit — handed over the
intermediate one and the fire-once guard swallowed the exit that matters.
ReplayBackfillRecords buffers the batch and lets only the newest exit-matching
record through; ESP failures and the Hello-wizard rail keep replaying
chronologically.
The replay ran before the IME state restore. ImeLogTracker.Start() restores the
persisted package states via LoadState() without raising OnAppStateChanged, and
ImeLogHost starts after EspAndHelloHost, so the replay evaluated an empty app
list — leaving the sharpest case unfixed: apps already terminal before the
reboot, only the final exit lost to the downtime. ImeLogHost gained an
onStateRestored callback wired to the re-check, and the host order is now pinned
by DefaultComponentFactoryOrderingTests so a reshuffle fails loudly.
11 new tests. Agent V2.Core 2340, DecisionCore 533, Backend 4287, all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven sits-d Cloud PCs enrolled the same day with identical configuration
(~138 required apps at the user-ESP gate, DeviceGuard/DmaGuard device-assigned).
The two that finished AccountSetup before the coalesced reboot landed succeeded;
the five the reboot killed in Stage=EspAccountSetup never derived a completion
again and ended Incomplete on healthy devices. Two defects behind that:
ShellCoreTracker.BackfillRecentEspExitEvents() has existed since session
772fe502 but no caller ever invoked it, so the Shell-Core watcher only saw
records written after Start(). Every agent restart silently dropped an ESP exit
(62407) or Hello-wizard start (62404) from its downtime. EspAndHelloHost.Start()
now calls it, and the fixed 5-minute window is sized to the actual downtime when
the previous run died to a reboot (LastBootUtc, clamped to 360 min).
The user-apps-settled synthesis ran only on the EspExited edge. When the ESP page
exits with apps still in flight the single attempt misses and nothing re-checks —
8110e262 and a89aac2d both reached 138/138 apps, 0 failed, and still never
completed. The exit edge is now recorded and the synthesis re-evaluated from
ImeLogTracker.OnAppStateChanged. No new completion path: the gate conditions are
unchanged, this only gives the existing conservative check a second look. The
fire-once claim moves to Interlocked (edge and re-check run on different threads)
and the re-check does not re-emit app_install_starved.
Rules: ANALYZE-ESP-005 gains on_event:session_stalled so a hung session produces
a finding at the 60-minute stall instead of staying finding-less for hours
(verified against 8110e262 — fired, confidence 95). New ANALYZE-APP-016 turns the
already-emitted app_install_starved events into a customer finding and, unlike
ANALYZE-APP-015, fires even on a succeeded session: 3b9291aa was recorded
Succeeded with 99 required apps that never started installing.
ANALYZE-APP-015 and ANALYZE-ESP-001 were considered for the stall trigger and
deliberately left alone — APP-015 is the documented worked example of the
terminal-precondition trap, and ESP-001's phase_duration gate on a still-open
phase is unverified mid-run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first CI run overwrote all four customer-facing scripts on the download
host and the legacy mirror with their own byte values as decimal text
(Install-AutopilotMonitor.ps1 went from 27331 to 95679 bytes).
PowerShell unrolls an array returned from a function into the output stream, so
the caller receives Object[] rather than byte[]. Invoke-RestMethod serialises an
Object[] body as space-separated decimals. Every hash check stayed green because
[byte[]] parameter coercion repairs the type everywhere except on the wire.
build-agent.yml's Send-Blob was never affected: it read the bytes inside the
function. Proven with a local HttpListener: the old shape puts 50 bytes starting
54,48,32,51,53 ("60 35 13 1...") on the wire where 17 bytes starting 60,35,13,10
("<#\r\n.SYN") are expected.
Fixes:
- ',' on both byte-array returns, plus an explicit [byte[]] at the upload site.
- Read each blob back from storage right after its PUT and abort before the
mirror and the remaining files. Verifying only at the end left every blob
corrupted.
- The version-bump guard tolerates an unparseable published copy with a warning
instead of throwing: that is the state a republish exists to repair, and a
guard that blocks the repair is worse than no guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Byte comparison made the guard fire on comment fixes, typos in comments and
reflow -- changes no consumer of the version can act on, so forcing a bump for
them is friction without a payoff.
The guard now compares a code fingerprint: the PowerShell token stream with
comments, newlines and line continuations removed. A behaviour change under an
unchanged $ScriptVersion still fails; comment and formatting changes publish
with a warning. That is also the rule the source file's own comment on
$ScriptVersion already states ("bump on meaningful changes").
Tokenising rather than regex-stripping keeps a '#' inside a string literal from
being mistaken for a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scripts behind download.autopilotmonitor.com/agent/ rotated only on a
publish_as_stable run of build-agent.yml, so a bootstrap fix sat unpublished
until the next agent cutover -- while the WDP bootstrap MSI downloads the
published copy on every enrolling device.
Publish-BootstrapScripts.ps1 becomes the single owner of the chain: dev render
with anchor check, $ScriptVersion bump guard, uploads (eu + fail-soft legacy
mirror, no-cache), the two version oracles, and verification through the alias.
publish-scripts.yml calls it on every push to main touching the sources; the
publish_as_stable branch of build-agent.yml calls the same code, so a release
still lands a consistent set.
Blobs are published in a deterministic CRLF/UTF-8-without-BOM form. Git
normalises line endings on checkout, so raw working-copy bytes would differ per
environment and make every byte comparison -- guard and verify alike -- depend
on where the workflow ran. The form reproduces the currently published bytes
exactly.
The Pester (PS 5.1) and ASCII gates move into the reusable
bootstrap-script-gates.yml and now block the upload; pester.yml keeps the
pull-request view and drops its push trigger. AdminConfiguration
.LatestBootstrapV2ScriptVersion gets exactly one writer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex findings on d8c84f41/a369ff99: (1) MarkUploaded now recomputes
_pendingBytes from the pending list when accounting drifts negative or
stays non-zero on an empty list — a silent negative would disarm the
spool-pressure trigger. (2) New test covers the DevicePrepCompletion
handler''s AwaitingDesktop fallback (stale/replayed timer without a
desktop fact): synthetic Hello timeout, park, and completion on the
later real desktop arrival. Third finding (HashingCappedReadStream
maxBytes:0) refuted — the 0=uncapped contract is implemented in the
same class and exercised by the heal-path tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99/100 spool_pressure_detected events in the field were file-size-only
triggers on a near-empty backlog (append-only spool.jsonl grows past 5 MB
in healthy long sessions). TelemetrySpool now tracks PendingBytes (exact
serialized size of the not-yet-uploaded tail, maintained on enqueue /
mark-uploaded / restart rehydrate) and the collector thresholds on that
instead of SpoolFileSizeBytes, which stays as an informational metric.
Snapshot gains spool_pending_bytes; pressure event data carries
pendingBytes + pendingBytesThreshold.
Also: startup recovery scan streams via File.ReadLines instead of
materializing the whole spool with File.ReadAllLines (corrupt-tail
break semantics unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hero gets a quiet checkmark chip row (user-driven, pre-provisioning,
self-deploying, Device Preparation, Windows 365, Entra ID & Hybrid join);
about page deployment claim now includes the MSI LOB channel for WDP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
afee7ae0 audit: WDP sessions ran the full Classic/ESP apparatus. Gates key on
the deterministic BootstrapperAgent ExecutionContext marker only (CloudAssigned*
fallbacks keep Classic behavior).
Paket A (event hygiene): no autopilot_profile/-missing + ZTD query/probe on WDP;
esp_config_detected null-gate (no more unknown/unknown); starved sweep skipped
on WDP (data stays in app_tracking_summary/final-status).
Paket B (resources): EspPolicyProviderStallHost + ProvisioningStatusTracker not
started on WDP; ModernDeployment noise IDs 100/417/418/1005 fully suppressed
(Critical always passes) + Autopilot error backfill skipped; StallProbe skips
ESP category scan and treats noise IDs as harmless.
Paket C (DecisionCore): enrollmentTypeDeterministic payload key seeds
DevicePreparation at High confidence (fallbacks stay Medium); completion_waiting
drops the unsatisfiable account_setup_provisioning_complete gate on WDP; arm D
promotes on real-user desktop; new device_prep_completion 30-min backstop closes
the park-until-watchdog hole; parked tripwire gets a WDP branch.
17 new tests; suites green (532/2307/4287). OKF doc agent/wdp-scenario-gates.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session bc803955 (DevicePrep, Succeeded) rendered "No application data
available" although the M365 CSP package installed during prep and IME
delivered 12 required user apps 9 seconds after final-status was written.
- EnrollmentTerminationHandler: bounded settle wait (20s, 250ms poll) before
the dialog snapshot - DevicePreparation-only and only while the tracker has
zero packages, so Classic pays nothing. Root cause of the lost race is IME
AppWorkload.log flush latency (~10-20s), not the 100ms tracker poll.
Dialog, starved sweep and app_tracking_summary all see settled data.
- FinalStatusBuilder: synthetic "Microsoft 365 Apps" row from the Office C2R
detector's persisted lifecycle state (Office CSP installs never flow through
the IME Win32 pipeline). Prepended to the Device bucket, counted in
appSummary; Preinstalled state deliberately excluded. No SummaryDialog.exe
change needed - it renders packageStatesByPhase generically.
- OfficeInstallStateData: CompletedAtUtc + VersionReached stamped on terminal
persists (duration/version for the row); older state files load fail-soft.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Timeline rows render time-of-day only, so a large gap between adjacent
events (and the date change it implies) was invisible without expanding
event details. Rows following a gap >= 1h now show an amber chip
(e.g. "+3h" / "+19d"); hovering reveals the full date. Applies to both
the phase timeline and raw mode, using the same display timestamp as
the row (backfilled events keep their event-log time).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session f475e697: a device re-enrolled without a wipe (IME log residue from a
previous enrollment) completes its pre-installed apps as instant detections,
pulling the phase anchors ~6.5 min ahead of the real ESP page — the breakdown
was mechanically exact but semantically wrong, and entered fleet stats as clean.
- New duration-critical flag PriorEnrollmentResidue, set on
historic_ime_replay_detected; excluded from fleet segment stats via the
existing DurationCriticalFlags mask. registry_app_baseline successes are
deliberately NOT a trigger (by-design normal for WDP Batch-1) — pinned by test.
- AttributionVersion 1 -> 2 so the maintenance sweep recomputes in-retention
breakdowns and flags old residue sessions retroactively (rule 8).
- Web: flag label in TimeAttributionLane; docs: quality-flags section.
- Stale comment fix: SaveWhiteGloveSuccessResult is NOT a WhiteGlove indicator
on Win11 25H2+ (validated 2026-08-18 against user-driven sessions) —
observability only, consumed nowhere for classification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Daily-review finding: the MSI upload is write-once (If-None-Match:*), so a
first attempt that died between the MSI and the provenance upload funneled
every retry into the 409 short-circuit — which returned "Archived" without
ever writing provenance.json (permanently lost). The 409 path now checks for
the sidecar and, when missing, rehashes the ARCHIVED bytes and writes it;
heal failures ride the normal retryable ladder.
Also closes the review's test gaps: ImeMsiArchiveQueueWorker + producer
worker-level tests (internal QueueClient test ctors, SessionDeletionWorker
pattern), CorporateIdentifierValidator null/whitespace + OData special-char
+ page-cap-transient tests, registry observer helper tests
(TerminalOutcome/ChangedFields/ClassifyStatusServiceStatus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live findings from the first two 2.0.1402 sessions (Oliver, 2026-08-18):
1. "Failed to get AAD token" is logged routinely during the device phase
(no user token exists yet) - both test sessions raised the Warning
although nothing was wrong. The failure is now held pending: any
IME-TOKEN-SUCCESS match clears it (staleness-gated so a replayed
success from a previous enrollment cannot mask a current outage), and
ime_token_failure fires only when no success follows for the 30-min
grace window, then deduped per errorCode. Genuine auth outages run
long enough to hit the window; routine device-phase failures never do.
2. New one-shot registry_app_baseline event (Info, only when non-empty):
summary of the Win32Apps state that predates the agent run - counts
by enforcement class + capped 50-app typed payload incl.
LastUpdatedTimeUtc. On WDP the DPP-policy apps install in Batch 1
before the bootstrap-script agent exists; their outcomes live only in
this baseline, closing the pre-agent app gap without log backfill.
The diff rail stays silent on baseline state (anti-replay unchanged).
Event type dual-registered (Constants + MCP catalog + manifests regen).
Tests: grace-window cycle (resolved / unresolved / re-arm dedup),
baseline summary (counts, one-shot, empty). All suites green
(Agent 2274, Functions 4262, MCP 561, web 762), 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WIX0103 on the first run: Invoke-BootstrapDownload.ps1 not found because
wix build resolves File/@Source relative to the working directory, not
the wxs location. Matches the local build documented in Package.wxs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. The plan file is `internal/work/plans/<slug>.md` from the first draft; the harness plan folder outside the repo is never the storage place, at most a copy for the approval dialog. If something goes sideways, stop and re-plan instead of pushing on.
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
Codex reviews your output once you are done.
## Where knowledge lives
| Content | Location | Rule |
|---|---|---|
| Customer documentation | separate repo `autopilotmonitor-docs` (GitBook; baked into the MCP `search_docs` corpus) | Any change ⇒ MCP redeploy. Commit in that repo (`cd` first). |
| Technical concept docs (OKF bundle) | `internal/` — private submodule, entry point `internal/docs/index.md` | Read the index first. Concept docs only for invariants, mechanisms and architecture a later change could silently break. |
| Decisions, including "we will not build X" | `internal/decisions.md` | One entry per decision: date, decision, why, applies-to, status. |
| Working files, lessons, plans | `internal/work/` | Rules in `internal/CLAUDE.md`. |
| Agent review criteria | `internal/docs/agent/architecture-principles.md` | Check every agent change against it. |
| Private operator instructions | `internal/CLAUDE.md` | Imported at the end of this file. Absent for contributors without submodule access; nothing in it may be copied into this public repo. |
| Infrastructure and operator scripts | `infra/` — private submodule | Bicep plus deploy/backup/migration scripts. Scripts never carry credentials; secrets come from env or a gitignored file next to the script. |
| Skills | `.claude/commands/` — private submodule | Each skill's frontmatter says when to use it. |
| Claude auto-memory | outside the repo, per machine | Private notes only. Anything the project must keep goes into `internal/`. |
## Hard rules
- Agent projects stay on .NET Framework 4.8.
- .NET build warnings are errors; only the NU1901–NU1904 advisories remain warnings.
- Never put real customer domains, tenant names or tenant IDs into code, tests, commits, comments or public docs.
- Deploy order is Backend → Web → MCP. Backend deploys are `workflow_dispatch` only. Verify the deployed version before claiming anything is live.
- Submodules (`internal/`, `infra/`, `.claude/commands/`): commit inside the submodule first, then bump the gitlink here.
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
## Code Quality
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
- Reuse before you write: search for an existing helper by what it does (its key call, literal or regex), not by a guessed name. Web helpers live in `lib/` (route-only ones in a `lib/` beside the route), backend helpers in `Functions/Helpers/`.
- Dead and copied code is gated: the .NET build fails on unused private members and parameters (`.editorconfig`), `npm run knip` covers web and MCP, and `npm run dup` in `tools/quality` fails on new clones. CI only reports, so run them before calling a change done.
- English for code comments and everything that carries knowledge.
## Task Management
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
## Customer-Facing Claims
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
## Technical Docs — OKF Bundle (`docs/`)
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
Private half of these instructions (process, working files, technical docs, operations): @../internal/CLAUDE.md
description:Something does not work as documented.
labels:[bug]
body:
- type:markdown
attributes:
value:|
Thanks for the report. Please redact tenant IDs, device names, serial numbers, and anything personal before posting.
For a failed enrollment in your own tenant, the fastest path is the **Report Session** button in the portal — it attaches the session context for support without exposing it here.
- type:dropdown
id:component
attributes:
label:Component
options:
- Web portal
- Agent (on device)
- Bootstrap script / Intune deployment
- MCP server / AI integration
- Analysis rules / log patterns
- Backend / API
- Documentation
- Not sure
validations:
required:true
- type:textarea
id:what
attributes:
label:What happened?
description:What did you do, what did you expect, what happened instead?
validations:
required:true
- type:textarea
id:repro
attributes:
label:Steps to reproduce
placeholder:|
1. …
2. …
- type:input
id:version
attributes:
label:Agent version / portal date
description:Agent version from the session details, or the date you saw it in the portal.
- type:textarea
id:logs
attributes:
label:Logs or screenshots
description:Relevant excerpts only, redacted. Paste text rather than screenshots of text where possible.
throw"Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See docs/versioning.md."
throw"Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See internal/docs/versioning.md."
public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);
Assert-That'impersonation runs as the test user'($script:probe.User-eq$userSid)"$($script:probe.User)"
Assert-That'the test user is no administrator'(-not(New-ObjectSystem.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
# A scratch ProgramData with the real one's permissions.
try{Initialize-AgentBase-Path$base}catch{$refused=$_.Exception.Message-like'*holds entries another account created*'}
Assert-That'planted entry: the install stops'$refused
Assert-That'planted entry: own leftovers are gone, the planted file stays'((-not(Test-Path-LiteralPath(Join-Path$base'old-installer.log')))-and(Test-Path-LiteralPath(Join-Path$base'planted.dll')))
Assert-Denied'planted entry: the protected folder takes no more'{[System.IO.File]::WriteAllText((Join-Path$base'second.dll'),'x')}
[System.IO.Directory]::Delete($base,$true)
# The user creates a run folder name first: it is never used.
- name:Every third-party action is pinned to a SHA
run:|
set -uo pipefail
failed=0
while IFS=: read -r file line ref; do
# Reusable workflows from this repository (workspace-relative ./ or the
# self-repository form $/) move with the commit that calls them, so there is
# nothing to pin.
case "$ref" in ./*|\$/*) continue ;; esac
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$) ]]; then
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
continue
fi
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vX.Y.Z' so the line stays reviewable."
elif [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+# ]]; then
echo "::error file=$file,line=$line::The version comment must name the exact release tag the SHA resolves to ('# vX.Y.Z'), not a floating major: '$ref'. Find it with: gh api repos/<owner>/<action>/git/matching-refs/tags/vN"
else
echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos/<owner>/<action>/git/ref/tags/<tag>"
fi
failed=1
# -H is load-bearing: with a single matching file grep omits the name, the
# fields shift by one and every diagnostic points at nothing.
description:'Build number override (leave empty to auto-increment from buildcounter.txt)'
required:false
type:string
channel:
description:'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
required:true
type:choice
options:
- release
- dev
default:release
upload_blob:
description:'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
required:true
@@ -27,17 +35,31 @@ on:
required:false
type:boolean
default:false
release_notes:
description:'Customer-facing notes for the GitHub Release (Markdown bullets). Empty = generic fallback linking to the docs changelog'
required:false
type:string
# Every job states its own permissions. This default is what the build job runs with: it
# executes third-party code (MSBuild targets, analyzers, test frameworks) and must never hold
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
This ZIP carries a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog]($env:DOCS_BASE_URL/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
}
# Previous release for the compare link. Fail-soft: the first release has none,
test -n "$NEXT_PUBLIC_API_BASE_URL" || { echo "::error::API_BASE_URL is empty - the portal would call localhost"; exit 1; }
test -n "$NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL" || { echo "::error::STATS_CONTAINER_URL is empty"; exit 1; }
test -n "$NEXT_PUBLIC_ENTRA_REDIRECT_URI" || { echo "::error::WWW_BASE_URL is empty"; exit 1; }
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
# Fail-soft like the star count: browsers still load the stats live; only crawlers and
# LLM fetchers, which run no JS, see no numbers until the next deploy.
grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
This applies to all project spaces: issues, pull requests, discussions, and the documentation repository.
## Expectations
- Be respectful and constructive. Disagree with ideas, not with people.
- Assume good intent. Most confusion is a documentation gap, not malice.
- Keep discussions on topic and searchable: clear titles, reproducible steps, text instead of screenshots of text where possible.
- Never post customer data, tenant IDs, device identifiers, tokens, or log excerpts containing personal data in public issues or discussions. Redact first.
## Not acceptable
Harassment, personal attacks, discriminatory language, publishing others' private information, and sustained disruption of discussions.
## Enforcement
The maintainers may edit, hide, or delete content and may block accounts that violate this code. To raise a concern, contact the maintainers privately through
[GitHub](https://github.com/okieselbach) rather than in a public thread.
Autopilot Monitor is developed and operated by its maintainers. The source is published so that customers and security reviewers can see what runs on their devices and with their data. This page explains where feedback goes.
| Ask a question or discuss an idea | [Discussions](https://github.com/okieselbach/AutopilotMonitor/discussions) |
| Report a security issue | [SECURITY.md](SECURITY.md) — privately, never as a public issue |
| Read how the product works | [docs.autopilotmonitor.com](https://docs.autopilotmonitor.com) |
## Reporting a problem in your own tenant
For a failed or odd enrollment, the **Report Session** button in the portal is the fastest path. It hands the session context to support without exposing any of it publicly. Use a GitHub issue for problems that are reproducible and not tied to your data.
Please redact tenant IDs, device names, serial numbers, and anything personal before posting in a public issue or discussion.
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.
Autopilot Monitor is an open-source, real-time monitoring and troubleshooting platform for **Windows Autopilot** and **Microsoft Intune** enrollment. It records enrollment events, application installations, policies, device state, and diagnostics while provisioning is still running, instead of collecting logs by hand after a failure. Administrators get live enrollment tracking, Enrollment Status Page (ESP) visibility, detailed session timelines, automated issue detection, on-demand diagnostics, fleet health analytics, and AI-assisted troubleshooting through a hosted Model Context Protocol (MCP) server.
## Private Preview
The platform supports classic Windows Autopilot and modern Intune provisioning scenarios, including **Windows Autopilot Device Preparation**.
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
## Availability
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.
<img src=".github/assets/SessionList.png" width="45%" alt="Autopilot Monitor dashboard showing live Windows Autopilot and Intune enrollment sessions" />
<img src=".github/assets/FleetHealth.png" width="45%" alt="Autopilot Monitor fleet health dashboard with Windows Autopilot enrollment analytics and success metrics" />
Autopilot Monitor provides real-time tracking, intelligent diagnostics, and automated troubleshooting for Windows Autopilot enrollment processes. It consists of:
- **Real-time Windows Autopilot monitoring** — follow device provisioning while enrollment is still running
- **Detailed enrollment timelines** — correlate Autopilot, ESP, Intune Management Extension, apps, policies, device state, and system events
- **Enrollment Status Page visibility** — see which phase, application, or policy is delaying provisioning
- **Automated issue detection** — define custom analysis rules that identify known failure patterns
- **Targeted evidence collection** — gather registry values, event log entries, files, log-parser matches, and allow-listed commands when specific conditions are detected
- **Centralized agent configuration** — control diagnostic behavior, automatic reboots, timezone adjustment, and other monitoring settings from the web dashboard
- **AI-assisted troubleshooting** — query enrollment data through the built-in Model Context Protocol server
- **Bootstrap Script** — PowerShell script deployed via Intune that starts monitoring early in the enrollment process
- **Monitoring Agent** — Lightweight .NET application that collects telemetry and evidence during enrollment
## How it works
Autopilot Monitor consists of several components working together throughout Windows enrollment:
- **Bootstrap Script** — PowerShell script deployed through Microsoft Intune that starts monitoring early in the provisioning process
- **Monitoring Agent** — lightweight .NET application that collects telemetry and evidence during enrollment
- **Backend API** — Azure Functions-based ingestion and processing pipeline
- **Web Dashboard** — Next.js application for real-time monitoring and fleet analytics
- **Web Dashboard** — Next.js application for real-time monitoring, troubleshooting, and fleet analytics
- **MCP Server** — Model Context Protocol server that lets AI assistants query Autopilot Monitor data in natural language
## Architecture
## Windows Autopilot and Intune Troubleshooting
For detailed information about the system architecture, components, and data flow, see [Architecture Documentation](docs/architecture.md).
Autopilot Monitor helps investigate common Windows provisioning and enrollment problems such as:
- Windows Autopilot deployments that fail or appear stuck
- Enrollment Status Page (ESP) delays and unusually long deployment phases
- Win32 application installation failures and slow application downloads
- Intune Management Extension problems
- Microsoft Entra join, Hybrid Join, and device registration issues
- policies that block or delay provisioning
- inconsistent behavior across hardware models and recurring failures affecting multiple devices
Because telemetry is collected during enrollment, many problems can be investigated without reproducing the issue or manually collecting logs from the affected device.
## AI Integration (MCP)
Autopilot Monitor ships a hosted **[Model Context Protocol](https://modelcontextprotocol.io)** server. Connect Claude Desktop, VS Code, or any MCP client that speaks Streamable HTTP with OAuth, and ask questions like:
- *"Show me all failed enrollments from the last 24 hours and group them by failure reason."*
- *"Why did session X fail, and which app install caused the delay?"*
- *"Which devices had unusually long ESP application phases this week?"*
- *"Which device models have the highest Autopilot failure rate?"*
- *"Which devices in my fleet are affected by CVE-2024-30078?"*
Server URL: `https://mcp.autopilotmonitor.com/mcp` — sign-in runs through your existing work account, access is scoped to your tenant exactly like in the portal, and the MCP server stores no credentials. Setup guide: **[AI Integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp)**.
## Documentation
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)** — deployment, configuration, monitoring, diagnostics, troubleshooting, analysis and gather rules, integrations, and AI-assisted troubleshooting.
## Open Source
Autopilot Monitor is developed openly on GitHub. The source is published so that customers and security reviewers can see what runs on their devices and with their data: the repository contains the device agent, shared library, backend, web dashboard, and MCP server.
## Feedback
Bug reports and feature requests go through [Issues](https://github.com/okieselbach/AutopilotMonitor/issues/new/choose), questions and ideas through [Discussions](https://github.com/okieselbach/AutopilotMonitor/discussions). Security issues are reported privately, see [SECURITY.md](SECURITY.md). For a problem in your own tenant, the **Report Session** button in the portal is the fastest path.
## License
This project uses a **split licensing model**:
This project uses a **split licensing model**. The root [LICENSE](LICENSE) is AGPL-3.0, which covers the server-side components; the device-side components carry their own MIT license file:
- **MIT License** — Agent (`src/Agent/`) and Shared library (`src/Shared/`) — unrestricted use on end-user devices
- **AGPL-3.0** — Backend (`src/Backend/`), Web Dashboard (`src/Web/`), and MCP Server (`src/McpServer/`) — server-side components remain open source
- **MIT License** — Agent ([`src/Agent/`](src/Agent/LICENSE)) and Shared library ([`src/Shared/`](src/Shared/LICENSE)) — unrestricted use on end-user devices, no copyleft obligations
- **AGPL-3.0** — Backend ([`src/Backend/`](src/Backend/LICENSE)), Web Dashboard ([`src/Web/`](src/Web/LICENSE)), and MCP Server ([`src/McpServer/`](src/McpServer/LICENSE)) — modifications to server-side components remain open source, especially when deployed as a network service
See [LICENSE](LICENSE) for full details.
The Shared library is MIT because it is a dependency of the MIT-licensed Agent. The license file inside each component directory is authoritative for that component.
Write-Host" 2. Start Functions: cd src\Backend\AutopilotMonitor.Functions && func start"-ForegroundColorGray
if($IncludeWeb){
Write-Host" 3. Start Web UI: cd src\Web\autopilot-monitor-web && npm run dev"-ForegroundColorGray
Write-Host" 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console"-ForegroundColorGray
}
else{
Write-Host" 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console"-ForegroundColorGray
description: Windows' own diagnostic surfaces for the Autopilot profile-download (ZTD) flow and known deployment error codes — what the agent reads, what each identifier means, and where the knowledge comes from.
| Event ID | Level | Meaning | Agent verdict mapping (`ztdVerdict`) |
| --- | --- | --- | --- |
| 100 | Warning | "Autopilot policy [name] not found" — temporary, device is waiting for the profile download. Windows can log hundreds of these per minute (harmless-rollup in `ModernDeploymentTracker`). | `waiting_for_profile_no_internet_confirmation` (when nothing else was logged) |
| 101/103/109/111 | Info | Profile settings retrieval/processing details. | not queried |
| 153 | Info | `ProfileState_Unknown → ProfileState_Available` — a profile was downloaded and is ready. | `profile_downloaded` |
| 160 | Info | Profile settings acquisition beginning. | context only |
| 163 | Info | Download not required — device already provisioned (typically after `Sysprep /Generalize`, i.e. Autopilot-for-existing-devices images). | `already_provisioned` |
| 164 | Info | Internet available to attempt policy download — the authoritative witness that the ZTD round-trip could run at OOBE time. | with no 161: `download_attempted_no_profile_returned` (the assignment-gap signature, session 423b5360) |
| 171 | Error | TPM identity confirmation failed (HRESULT in payload) — self-deploying/pre-prov attestation. | context only (`inconclusive` if alone) |
| 172 | Error | Failed to set profile as available — usually follows 171. | context only |
| 807 | Error | `ZtdDeviceIsNotRegistered` — hardware hash not registered in Autopilot. | `device_not_registered` |
| 809 | Error | `ZtdDeviceHasNoAssignedProfile` — the assigned profile was deleted without cleanup. | `assigned_profile_deleted` |
| 815 | Error | `ZtdDeviceHasNoAssignedProfile` — no profile assigned and no tenant default profile. | `no_profile_assigned` |
| 908 | Error | `SerialNumberMismatch` / `ProductKeyIdMismatch` between Autopilot registration and physical hardware. | `serial_or_product_key_mismatch` |
Verdict priority: error IDs are authoritative (807 → 908 → 809 → 815), then the Info-flow
reconstruction (161/153 → 163 → 164 → 100). Implemented in
`ZtdEvidence.ComputeZtdVerdict`, pinned by `ZtdEvidenceVerdictTests`.
# Schema — Diagnostics registry key
`HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot` — written by the ZTD client,
dumped verbatim by the agent into the `autopilot_profile` event (`diagnosticsRegistry`):
| Value | Meaning |
| --- | --- |
| `AadTenantId` | GUID of the tenant the user signed into. |
| `CloudAssignedTenantDomain` | Tenant the device is registered with; **blank = not registered with Autopilot**. |
| `CloudAssignedTenantId` | GUID matching `CloudAssignedTenantDomain`; blank when not registered. |
| `IsAutopilotDisabled` | 1 = device not registered **or** profile download failed (network/firewall/timeout) — Microsoft's own catch-all indicator. **Casing caveat:** Windows actually writes `IsAutoPilotDisabled` (capital P; observed session e7dbac7a, 24H2) while the FAQ documents `IsAutopilotDisabled` — all agent lookups are case-insensitive for this reason. |
| `TenantMatched` | 0 = user's tenant ≠ registration tenant → the "username belongs to another organization" OOBE error. |
The profile cache itself lives in `HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache`
(`PolicyJsonCache`, `ProfileAvailable`) — `ProfileAvailable=0` plus an empty
`ZeroTouchConfig` is the "blank profile cached" state the troubleshooting FAQ describes
when no profile exists at check time. A reboot re-runs the download and can replace the
blank cache.
# Schema — Endpoints
| Endpoint | Purpose | Agent behavior |
| --- | --- | --- |
| `https://ztd.dds.microsoft.com` | Autopilot Deployment Service — profile delivery during OOBE. | Reachability probe on profile-missing (any HTTP status = reachable). **Note:** several third-party blogs cite `ztd.ssd.microsoft.com`; the official requirements page says `ztd.dds.microsoft.com`. |
| `https://login.live.com` | Second Deployment-Service URL; also needed by the Microsoft Sign-in Assistant (`wlidsvc`) — a policy that disables wlidsvc breaks profile download. | not probed |
| `*.microsoftaik.azure.net` (+ per-vendor EK cert URLs) | TPM attestation for self-deploying/pre-provisioning. | not probed |
| `lgmsapeweu.blob.core.windows.net` | Intune automatic diagnostics upload. | deliberately not probed (decision 2026-07-16: not mission-critical) |
# Observability — when the agent can actually see these errors
The agent's install timing bounds what the known-issue rules can ever observe. In the
normal deployment model the agent is installed by an **Intune platform script via IME —
i.e. only AFTER MDM enrollment succeeded**. Everything that happens before that point
(profile download, TPM attestation in self-deploying/pre-provisioning, the MDM enrollment
handshake itself) is over before our process exists.
| Failure class | Normal flow (IME install) | Bootstrap token mode | Await-enrollment mode |
| --- | --- | --- | --- |
| Enrollment blocked (0x80180014/18, 0xC1036501, 0x801C03F3 → ANALYZE-ENRL-004) | **Invisible** — no enrollment ⇒ no agent, no session. | **Visible** — the bootstrap token exists precisely to report pre-MDM (as of 2026-07: one customer piloting). | **Invisible to the backend** — the agent binary is present but only polls for the MDM certificate; without the cert it cannot authenticate, and monitoring starts only after the cert appears. A hard blocker ends in the await timeout (default 480 min) with nothing uploaded. |
| TPM attestation (ANALYZE-ENRL-003) | **Visible via error backfill** — attestation precedes enrollment, but the startup error backfill (below) replays Autopilot-channel Level 1-2 records from the last 4h, so retry-then-success attempts surface as `backfilled=true` events. | Visible. | Visible via error backfill once monitoring starts. |
| Hybrid-join 0x80004005 (ANALYZE-ENRL-005) | **Visible** — the ODJ/hybrid phase runs during device ESP while the agent is active. | Visible. | Visible after cert arrival. |
| Clock skew (ANALYZE-DEV-007) | **Always** — ntp_time_check is our own event. | Always. | Always. |
The rules stay enabled despite the narrow normal-flow windows: they are evidence-gated
(zero cost when the codes never appear) and they future-proof the bootstrap and
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
records **oldest-first** so the timeline sequence follows event-log chronology.
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
Error filter.
# Schema — Error codes (backend known-issue map)
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
failures we can observe:
| Code | Meaning / context | Actionable guidance |
| --- | --- | --- |
| `0x800705B4` | Timeout; in self-deploying mode usually "device is not TPM 2.0 capable" (e.g. a VM). | Don't use self-deploying on VMs / non-TPM-2.0 hardware. |
| `0x801C03F3` | Entra device object for the Autopilot device was deleted (logged in `Microsoft-Windows-User Device Registration/Admin`). | Deregister + re-register the device (recreates the object). |
| `0x80180014` | Device-record reuse in self-deploying/pre-prov mode, or Windows MDM enrollment disabled ("Enrollment blocked for AP device by SDM One Time Limit Check"). | Intune → Autopilot device → **Unblock device**, or allow Windows (MDM) in enrollment restrictions. |
| `0x80070774` | Hybrid-join ESP failure — domain mismatch between Intune Connector for AD and device targeting. | Install/configure the connector in the matching domain. |
| `0x80004005` | Hybrid-join deployment timeout — **build-dependent known issue**, fixed in KB5065789 (25H2, ≥ 26200.6725), KB5065426 (24H2, ≥ 26100.6584), KB5070312 (23H2, ≥ 22631.6276). | Update the image / install the KB. |
Related non-code known issues worth remembering: clock skew of minutes causes TPM
attestation errors and ESP timeouts (`w32tm /resync /force`; the agent's `ntp_time_check`
event carries the measured offset); multiple/unexpected OOBE reboots can delete kiosk
autologon registry entries (fixed in 24H2 KB5058411); ESP does not support mixing LOB and
Win32 apps ("Another installation is in progress").
title: Agent Build Numbers — Shared Counter Blob with ETag-CAS
description: Why agent build numbers come from a public counter blob reserved before the build, how the compare-and-swap protocol works, and what the repo's buildcounter.txt still does.
resource: /.github/workflows/build-agent.yml
tags:
- agent
- build
- ci
- release
- versioning
timestamp: 2026-07-17T00:00:00+02:00
---
# Agent Build Numbers — Shared Counter Blob
Agent (V2 line) build numbers — the `<n>` in `2.0.<n>` — are minted from a single
shared counter blob so that **local builds (`scripts/Deployment/build.ps1`) and CI
builds (`.github/workflows/build-agent.yml`) can never collide**. Both build paths
stay first-class: local daily-ops builds remain possible, CI builds add provenance
attestation on top.
# Schema
The reserve-before-build CAS protocol itself is shared with the backend and the MCP
server and is documented once in [Version Contract](../versioning.md); the single
implementation is `.github/scripts/Request-BuildNumber.ps1`. Agent-specific:
description: The pure-reducer state machine that decides enrollment completion/failure — signals, state, effects, the A/B/C completion arms, and its invariants.
- **`InformationalEvent`** — the single-rail pass-through: telemetry that must appear
on the Events timeline but never influences decisions.
**Ordering is the ingest ordinal, never wall-clock.** Replayed IME/CMTrace lines carry
backdated source timestamps; all before/after comparisons in the reducer therefore
compare `SourceSignalOrdinal`s (e.g. "final exit after AccountSetup entry").
# Effects — the only side-channel
`DecisionEffect` kinds and how the host's `EffectRunner` treats failures:
| Kind | Purpose | Failure class |
| --- | --- | --- |
| `ScheduleDeadline` / `CancelDeadline` | arm/cancel OS timers that come back as `DeadlineFired` | **critical** — failure aborts the session (a lost timer means silent parking) |
| `PersistSnapshot` | hint to write the recovery snapshot | transient |
| `RunClassifier` | scenario classification; verdict returns as a `ClassifierVerdictIssued` signal | optional — exception ⇒ Inconclusive, never aborts |
Terminal and state-changing timeline effects carry a structured **audit trail** in
`TypedPayload` (built by `DecisionAuditTrailBuilder`): `decisionSource`, `trigger`,
description: How the backend re-homes agents to a new API base URL (backend move or per-tenant region move) via MigrateToApiBaseUrl on the agent config response, and why HTTP redirects were rejected.
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` |
title: Hello Wizard Un-Skip — vetoing the policy-disabled Hello shortcut
description: Why a single "WHfB disabled" policy read is not trusted for completion decisions — the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
Reading order for newcomers: overview → decision engine → persistence.
description: Every file the agent persists under %ProgramData%\AutopilotMonitor, why each exists (especially the signal log), and the exact recovery/replay flow after a crash or reboot.
| Startup-event gate | `State\startup-event-state.json` | JSON dict | no (atomic) | cross-reboot dedup of one-shot probes |
| Bandwidth state | `State\bandwidth-state.json` | JSON samples/counters | no (atomic) | reboot-survivor for the DO bandwidth estimate |
| Session identity | `session.id`, `session.created`, `whiteglove.complete` (root) | text | no | drives emergency-break age check + WG resume |
| Markers | `clean-exit.marker` (root), `enrollment-complete.marker` (State), `quarantine-requested.marker` (State) | text | no | consumed at next start |
title: MDM Reboot Coalescing — attributing the mid-ESP reboot and second sign-in to policy URIs
description: How the agent observes reboot-required MDM policy flags (DM-Enterprise EventID 2800, aggregated) and how ANALYZE-ESP-005 attributes an actually-observed enrollment reboot to them — including why the raw events stay neutral and why the Shell-Core RebootCoalescing match was removed.
confidence: `uriCount ≥ 2` (+10), `omadmRebootRequiredFlag == true` (+15) on base 70 /
threshold 70. Remediation: map URIs to Intune profiles, reassign device → user groups (or
exclude the Autopilot device group), or accept + communicate the restart.
# Non-goals / invariants
* Event type **deliberately distinct from `system_reboot_detected`** — the backend
RebootCount matches that string exactly in two places
(`EventIngestProcessor.Classification` + terminal recount); attribution must never
inflate the reboot count.
* No session-level field / SessionInfoCard change in this iteration (follow-up candidate).
* The `RebootRequiredURIs` catalog itself is not read; only the 2800 events (which carry
the actually-triggered URIs) are consumed.
# Citations
* [Patch My PC — Avoid the second sign-in screen after Autopilot](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/)
* [Microsoft Learn — Enrollment Status Page tracking](https://learn.microsoft.com/en-us/autopilot/enrollment-status)
description: How the V2 agent boots during Autopilot enrollment, which collectors it runs, how telemetry flows to the backend, and how it decides to stop.
title: Secure Boot CA-2023 Detection — firmware evidence vs. Windows servicing status
description: Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly (GetFirmwareEnvironmentVariableExW + SeSystemEnvironmentPrivilege) instead of trusting the SecureBoot\Servicing registry key, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant rule suppression across old and new agents.
title: Business Timestamps vs. the Azure Tables System Timestamp
description: Why the system Timestamp is never authoritative, how the 2026-07-18 migration exposed that, and the OccurredUtc + RowKey-decode compensation (mappers, RowKey-range date filters, retention, backfill).
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: 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.
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.
# 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.
* [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.
# 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.
* [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.
* [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.
# 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.
# 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.
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
# Conventions for this bundle
* Every concept document is a markdown file with YAML frontmatter; `type` is mandatory, `title`, `description`, `resource`, `tags`, and `timestamp` are recommended.
* Use standard **relative** markdown links between documents (e.g. `agent/overview.md`, `../architecture.md`). Do NOT use the spec's `/`-prefixed bundle-absolute form — GitHub resolves those against the repo root (missing `/docs`) and navigation breaks.
* Record notable additions and changes in [log.md](log.md).
*`index.md` and `log.md` are reserved names — never use them for concept documents.
* **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.
* **Update**: `url-registry.md` — the first release after the alias migration surfaced Front Door caching as a correctness hazard: the alias served the previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) after the origin had rotated, and since manifest and ZIP are independent cache entries, a stale manifest with a fresh ZIP fails the bootstrap SHA-256 check. Route caching on `/agent/*` is now disabled and every blob upload stamps `Cache-Control: no-cache` (CI `Send-Blob`, local `build.ps1`, `versions/*.json` manifests) — all agent-container blobs rotate in place, so nothing there is safely cacheable.
* **Update**: `agent/decision-engine.md` — the RealmJoin completion gate has a third terminal, `FirstDeploymentIncomplete`. Session 224b2087 showed why: an interactive logon 17 s after DeploymentPhase 101 makes RealmJoin reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never 110 — the gate stayed closed until the 60-min hard timeout although Hello and desktop were long resolved. `realmjoin_phase_changed` is now a typed `RealmJoinPhaseChanged` signal, so `RealmJoinFacts.LastDeploymentPhase` is persisted across agent restarts (this also fixes every `realmjoin_timeout` event claiming "last phase: 0"); a transition out of 100/101 into 200/210 releases the gate with the `realmjoin_first_deployment_incomplete` Warning, and the `RealmJoinDetected` replay path evaluates the same rule for restarts that swallow the transition. A first observation of 210 deliberately does not release — session 6f1959c0 started at 210 and only deployed afterwards.
* **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301.
## 2026-07-22
* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`<major>.<minor>.<build>` + 7-char commit + build timestamp) and one implementation of the build-number reservation (`.github/scripts/Request-BuildNumber.ps1`, previously copy-pasted between `build-agent.yml` and `build.ps1`). The backend had been pinned at a hand-written `1.0.0` since inception and now starts at `1.5.1000`; the MCP server's `package.json` version was bumped by hand and now starts at `1.5.300`, and it reports its own commit on `/health` instead of only the documentation bundle's. Both deploy workflows poll their health endpoint until it reports the build just deployed and publish `versions/<component>.json` only then, so a manifest that disagrees with the live endpoint means the instance drifted rather than the pipeline failing halfway. `agent/build-counter-blob.md` keeps the agent-specific parts and now points at the shared protocol; its counter URL was still the pre-cutover storage account.
* **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout.
* **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests``[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load.
## 2026-07-21
* **Update**: `search_docs` and `search_knowledge` now state in their tool descriptions that queries must be in English. Both corpora and `all-MiniLM-L6-v2` are English-only, and the failure is total rather than gradual — a German question returns zero results, not weak ones. The caller is a language model, so translating the question before the call and answering in the user's language costs nothing; a multilingual embedding model would be the alternative but is much larger and would have to be measured against the eval harness first, since it changes English retrieval too.
* **Update**: Added `scripts/eval-docs-search.ts`, an offline retrieval evaluation harness (2465 queries in three classes: chunk headings as a regression detector, rare identifiers, and hand-written questions as the reality anchor), and used it to close a third `search_docs` gap found during the live test. The keyword fallback only fired when semantic search returned *fewer* than `topK`; a pasted identifier like `WOW6432Node` instead returned three chunks at ~0.29 that did not contain the word while the one chunk that did was never considered. It now also fires on a weak semantic head (<0.35), letting exact matches take the front while keeping one semantic result. The threshold is measured, not guessed: 0.35 fixes 335 queries and breaks 0 (identifier recall 66.2% → 81.1%), while 0.40 fixes more but starts demoting hand-written questions. The measurement also resized the problem — identifier queries were only succeeding 66.2% of the time, so the reported case was one of roughly 750.
* **Update**: Closed the two measured recall gaps in `search_docs` (`mcp/docs-corpus.md`). (1) **Token windows** — 87 of 324 chunks exceeded the model's 256-token context and 13% of all tokens never reached the ranking; a character budget cannot fix this (the corpus ranges 2.43–5.81 chars/token), so long text is now embedded as 250-token windows with 40 overlap and scored by its BEST window (`embeddings: number[][]`, max not mean). Token coverage 87% → 100%; "proxy configuration blocking app downloads" went from absent to 0.559 at rank 1; the ten reference queries kept byte-identical scores. Costs +31% vectors, index 2.63 → 3.12 MB. The rules corpus gained the same treatment (128 rules → 172 vectors). (2) **Keyword fallback** — when the semantic pass returns fewer than `topK`, results are topped up via literal `lexicalMatch` on distinctive query terms, requiring two matching terms for corroboration (one incidental substring match returned a page about LLM providers for a sourdough query). Fuzzy matching was measured and rejected: at a threshold low enough to surface "RealmJoin" it invents 5 hits for "SCEPman", which the corpus never mentions. Also sharpened the tool description with the measured context argument for preferring it over fetching the docs site.
* **Creation**: Added `mcp/docs-corpus.md` (new `mcp/` section) — the MCP server gains `search_docs`, a third semantic corpus built from the published customer documentation repository (`autopilotmonitor-docs`, checked out into the Docker build context by the deploy workflow). Documents the h2/h3 + `<details>` chunking with breadcrumb prefixes, the two deliberate size exceptions (tables never split, unbroken changelog lists split at line level), the CRLF precondition (`.` does not match `\r` in JS — headings silently stop matching, which produced 286 wrong-but-plausible chunks before it was found), the optional `docs` section in `search-index.json` and its validation asymmetry, embedding rounding to 6 decimals, and the cross-repo coupling: a docs edit reaches clients only after an MCP redeploy, made checkable via `DOCS_COMMIT` on `/health`. Container resources raised to 0.5 vCPU / 1.0 GiB for the cold-start query-embedder load.
## 2026-07-20
* **Update**: `rules/gather-rule-phase-scoping.md` — the gather-rule form now shows the phase-scope and emit-mode controls only where they can change behavior (`supportsPhaseScope` / `supportsEmitMode`), nulls hidden-control state out of the payload, and rejects a scope mode selected but left unfilled. Offering all three "when" controls unconditionally allowed combinations that could only suppress a one-shot rule, plus a silent no-op state.
* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
* **Creation**: Added `backend/business-timestamps.md` (new `backend/` section) — the 2026-07-18 migration reset every row's system Timestamp, exposing that AuditLogs/OpsEvents/Events had (unknowingly) displayed and filtered on it all along; documents the `OccurredUtc` column, the RowKey decoders and RowKey-range date/retention clauses in `BusinessTimestamp`, the GUID-guard trap, the audit/ops backfill endpoint, and the "system Timestamp is never authoritative" migration rule.
* **Creation**: Added `rules/gather-rule-phase-scoping.md` (new `rules/` section) — gather rules gain `activePhases` (run only during listed phases), `activeFromPhase` (sticky latch from a phase onwards, never via Failed) and `emitMode: "on_change"` (canonical-hash result dedup with `suppressedPolls` observability), ConfigVersion 34. Includes the prerequisite backend fix: the custom-rule toggle PUT no longer wipes the stored rule definition (partial-merge in `GatherRuleService.UpdateRuleAsync`).
## 2026-07-18
* **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.
## 2026-07-16
* **Creation**: Added `agent/autopilot-ztd-diagnostics.md` — reference for Windows' own ZTD diagnostic surfaces (ModernDeployment Autopilot event IDs incl. 807/809/815/908, the `Diagnostics\Autopilot` registry key, deployment-service endpoints, known-issue error-code map with KB fixes). Backs the agent's `ZtdEvidence` collector (`ztdVerdict` on `autopilot_profile_missing`) and the backend known-issue rules; sources carry a re-check RSS feed.
## 2026-07-13
* **Removal**: Moved `customer-graph-add-on-permissions.md` out of this contributor bundle into the customer documentation repository (`reference/optional-graph-permissions.md`). It was customer-facing product content and did not belong in the tech-docs bundle; removed the entry from `index.md`.
* **Creation**: Added `agent/hello-wizard-unskip.md` — session 772fe502 fix: the `HelloWizardStarted` decision signal (prevention veto + un-skip cure for the policy-disabled Hello shortcut) and the HelloTracker confirmation second read against flip-flopping WHfB CSP values.
## 2026-07-11
* **Creation**: Added the `agent/` section — three concept documents on the V2 agent: `agent/overview.md` (runtime, collectors, telemetry pipeline), `agent/decision-engine.md` (DecisionCore reducer, signals, A/B/C completion arms, invariants), `agent/logs-and-persistence.md` (persisted artifacts, signal log vs spool, recovery/replay flow) plus `agent/index.md`.
* **Creation**: Converted `docs/` into an OKF v0.1 knowledge bundle — added YAML frontmatter to `architecture.md`, `mcp-oauth-flow.md`, and `customer-graph-add-on-permissions.md`; created `index.md` and this log.
description: How the two unrelated identities (AI-product account vs. Entra ID work account) and three parties interact when connecting an AI client to the Autopilot Monitor MCP server.
title: MCP Docs Corpus — search_docs and the Cross-Repo Build Coupling
description: How the published customer documentation becomes a third semantic search corpus in the MCP server — CRLF-safe heading chunking, build-time embedding, the corpus hash guard, and why editing the docs does not update a running server.
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`.
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
# Schema
Allowlists come from `rules/guardrails.json`, embedded into the agent assembly as
`AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json` (see the `EmbeddedResource`
item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal's
`utils/guardrails.generated.ts` by `node rules/scripts/combine.js`.
| Guard | Source list | Match rule |
| --- | --- | --- |
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
Interval gather rules (e.g. a registry poll every 60 s) ran during **all** enrollment phases
and emitted an event on **every** tick — including `exists=false` events while the target key
did not exist yet. A rule needed only from Account Setup onwards produced hundreds of
identical noise events per session and wasted collector runs.
# Schema
Three opt-in fields on `GatherRule` (Shared model, `rules/schema/gather-rule.schema.json`,
delivered to the agent via `GET agent/config`; ConfigVersion 34). Absent fields = legacy
behavior — full back-compat.
| Field | Type | Semantics |
|---|---|---|
| `activePhases` | `string[]` \| null | Rule runs only while the current phase is in the list. Null/empty = unrestricted. |
| `activeFromPhase` | `string` \| null | Sticky latch: activates when the phase first reaches the threshold, then stays active for the rest of the session — including through `Failed`. Null = unrestricted. |
| `emitMode` | `"always"` \| `"on_change"` \| null | `on_change`: poll on the trigger cadence, emit only when the collected result changes. Null/`always` = emit every collection (legacy). |
Canonical phase tokens are the `EnrollmentPhase` enum **names**`Start`(0) …`Complete`(7).
title: URL Registry — One Place per Component, Guard-Enforced
description: Every well-known own or Microsoft host lives in exactly one registry file per component (Constants.cs, utils/config.ts, src/config.ts); guard tests fail the build on any literal outside them. Includes the agent-download host migration to the download alias.
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: 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 client secret for OAuth token exchange')
paramentraClientSecretstring=''
@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).')
parammcpAllowedRedirectHostsstring=''
@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).')
parammcpPublicUrlstring=''
@secure()
// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode
// >=). `az bicep build --stdout` writes through the Windows console codec and
// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine.
@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
// --- Azure Container Registry ---
// Admin user is DISABLED: a registry-wide username/password pair is a static,
// non-rotating credential that would also have to live in a Container App secret.
// The app pulls with its system-assigned identity + AcrPull instead (see below).
"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.",
"remediation":[
@@ -58,5 +71,30 @@
]
}
],
"tags":["apps","disk-space","critical"]
"presentation":{
"role":"cause",
"summary":"Free disk space dropped to {{low_disk.value}} GB",
"description":"Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
"severity":"high",
"category":"apps",
"version":"1.1.0",
"version":"1.3.0",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence":70,
"conditions":[
{
@@ -31,5 +35,31 @@
]
}
],
"tags":["apps","reboot","retry","loop"]
"presentation":{
"role":"cause",
"summary":"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
"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.",
"title":"App Reverted to Error After Successful Install",
"description":"Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
"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.3.0",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence":90,
"preconditions":[
{
@@ -69,8 +73,40 @@
}
],
"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.3.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.",
@@ -44,7 +93,7 @@
"steps":[
"Open Event Viewer → Applications and Services Logs → Microsoft → Windows → AppXDeploymentServer → Operational on the device (or in the diagnostics archive if uploaded)",
"Filter for Error-level events around the ESP failure time and confirm the package name and HRESULT match this finding",
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = invalid/corrupt package, 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = package could not be opened (unsigned, publisher mismatch, or not found), 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
]
},
{
@@ -65,8 +114,51 @@
}
],
"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.1.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",
"title":"Required Apps Never Started — ESP Apps Gate Starved",
"description":"One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
"severity":"warning",
"category":"apps",
"version":"1.1.0",
"author":"Autopilot Monitor",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed",
"on_event:session_stalled"
],
"trigger":"single",
"baseConfidence":75,
"confidenceThreshold":70,
"conditions":[
{
"signal":"starved_apps_present",
"source":"event_count",
"eventType":"app_install_starved",
"operator":"count_gte",
"value":"1",
"required":true
},
{
"signal":"many_starved_apps",
"source":"event_count",
"eventType":"app_install_starved",
"operator":"count_gte",
"value":"5",
"required":false
},
{
"signal":"appName",
"source":"event_type",
"eventType":"app_install_starved",
"dataField":"appName",
"operator":"exists",
"value":"",
"required":false
},
{
"signal":"state",
"source":"event_type",
"eventType":"app_install_starved",
"dataField":"state",
"operator":"exists",
"value":"",
"required":false
}
],
"confidenceFactors":[
{
"signal":"many_starved_apps",
"condition":"exists",
"weight":15
},
{
"signal":"appName",
"condition":"exists",
"weight":10
}
],
"explanation":"At least one **required** app assigned to the user ESP never started installing. The Intune Management Extension never moved it out of its initial state (`{{state}}`) — it did not download, did not install, and did not fail — while the ESP AccountSetup apps gate was waiting on it. First app observed: `{{appName}}`.\n\nEvery starved app has its own `app_install_starved` timeline event carrying `appId`, `appName` and `state` — open the timeline and filter on that event type for the complete list.\n\nThis is a **delivery** problem, not an install error, and it is invisible in the Intune console: the app simply never appears. Typical causes:\n\n- far more required apps targeted at the ESP than the gate can service in the available window,\n- requirement rules that are never met on this device (OS / architecture / registry / script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app that needs a context unavailable during provisioning (e.g. a VPN client waiting on a portal sign-in),\n- a mid-ESP reboot that cut the gate short — check whether the mid-ESP reboot advisory also fired for this session.\n\nNote that this finding is independent of the session verdict. A session can be marked **Succeeded** and still be missing every app listed here.",
"remediation":[
{
"title":"Shrink the ESP blocking set",
"steps":[
"Open the Enrollment Status Page profile and review the blocking app list — every required app there must finish before the user reaches the desktop.",
"Move apps that do not have to be present at first logon from *required* to *available*, or remove them from the ESP's blocking apps selection.",
"Prefer a small, deliberately chosen blocking set (security agent, VPN, management tooling) over the full application catalogue."
]
},
{
"title":"Unblock the individual apps",
"steps":[
"Collect the app names from the `app_install_starved` timeline events in this session.",
"For each app, review its requirement rules and dependencies in Intune — IntuneManagementExtension.log records every requirement evaluation.",
"Verify the assignment actually targets the signed-in user (user-targeted apps need an established Entra user affinity; check the hybrid user-affinity finding if it also fired).",
"Re-run an enrollment and confirm the apps now reach a terminal state before the ESP exits."
]
},
{
"title":"Rule out a truncated gate",
"steps":[
"Check whether a mid-ESP reboot was observed for this session — a forced restart during AccountSetup cuts the apps gate short and can starve everything still queued.",
"If so, reassign the reboot-forcing device policies to user groups so the restart lands after AccountSetup."
]
}
],
"relatedDocs":[
{
"title":"Microsoft Learn: Enrollment Status Page — blocking apps",
"title":"App Install Blocked by Another Installer (0x80070652)",
"description":"Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity":"high",
"category":"apps",
"version":"1.1.0",
"author":"Autopilot Monitor",
"enabled":true,
"baseConfidence":70,
"conditions":[
{
"signal":"mutex_blocked_install",
"source":"event_type",
"eventType":"app_install_failed",
"dataField":"hresultFromWin32",
"operator":"equals",
"value":"-2147023278",
"required":true
},
{
"signal":"installer_exit_1618",
"source":"event_data",
"eventType":"app_install_failed",
"dataField":"exitCode",
"operator":"equals",
"value":"1618",
"required":false
},
{
"signal":"esp_failed_with_mutex_code",
"source":"event_data",
"eventType":"esp_failure_settle_started",
"dataField":"errorCode",
"operator":"equals",
"value":"0x80070652",
"required":false
}
],
"confidenceFactors":[
{
"signal":"installer_exit_1618",
"condition":"exists",
"weight":10
},
{
"signal":"esp_failed_with_mutex_code",
"condition":"exists",
"weight":20
}
],
"confidenceThreshold":65,
"explanation":"App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
"remediation":[
{
"title":"Identify the competing installer",
"steps":[
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
]
},
{
"title":"Serialize the install chain",
"steps":[
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
]
}
],
"relatedDocs":[
{
"title":"Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
"title":"App Return Code Requested a Reboot During Enrollment",
"description":"Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
"severity":"warning",
"category":"apps",
"version":"1.1.0",
"author":"Autopilot Monitor",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"trigger":"single",
"baseConfidence":60,
"conditions":[
{
"signal":"reboot_return_code",
"source":"event_data",
"eventType":"app_install_completed",
"dataField":"exitCodeClass",
"operator":"in",
"value":"SoftReboot,HardReboot",
"required":true
},
{
"signal":"hard_reboot_return_code",
"source":"event_data",
"eventType":"app_install_completed",
"dataField":"exitCodeClass",
"operator":"equals",
"value":"HardReboot",
"required":false
},
{
"signal":"reboot_observed",
"source":"event_count",
"eventType":"system_reboot_detected",
"operator":"count_gte",
"value":"1",
"required":false
}
],
"confidenceFactors":[
{
"signal":"hard_reboot_return_code",
"condition":"exists",
"weight":25
},
{
"signal":"reboot_observed",
"condition":"exists",
"weight":15
}
],
"confidenceThreshold":60,
"explanation":"App **{{appName}}** returned exit code **{{exitCode}}**, which its Intune return-code table maps to **{{exitCodeClass}}** - the installer asked for a restart before its work is complete.\n\n- **SoftReboot**: the Intune Management Extension reports the app as *pending reboot*. The Enrollment Status Page treats it as completed and moves on, and Intune only marks the app installed after the next restart re-evaluates the detection rule. Until then the app may be unusable or half-configured, and the user sees a restart notification.\n- **HardReboot**: the Intune Management Extension stops processing further apps and, in Device Setup, restarts the device right away (the restart grace period does not apply during the ESP) - the same mid-enrollment restart and second sign-in that ANALYZE-ESP-005 attributes to reboot-required policies. In Account Setup the extension does not restart the device itself and the app stays pending.\n\nA restart requested by an installer is normal for many MSI packages (exit codes 3010 and 1641), but during Autopilot it lengthens the ESP, can surprise the user with a restart, and hides the app's real state until after the reboot. The return-code class is the app's own configuration in Intune, not a device fault.",
"remediation":[
{
"title":"Check the app's return-code mapping",
"steps":[
"Open the app in the Intune admin center > Properties > Program and review the return codes table: exit code {{exitCode}} is currently mapped to {{exitCodeClass}}",
"If the installer only recommends a restart (MSI 3010 ERROR_SUCCESS_REBOOT_REQUIRED, 1641 ERROR_SUCCESS_REBOOT_INITIATED), map the code to Soft reboot instead of Hard reboot so the next app can install before the restart",
"If the app works without an immediate restart, suppress the installer's own reboot (for MSI: REBOOT=ReallySuppress on the install command) so it returns 0 and the restart happens on your schedule",
"Keep Hard reboot only when the following apps genuinely cannot install before the restart - and then expect the mid-ESP restart in every enrollment"
]
},
{
"title":"Take the app out of the ESP critical path",
"steps":[
"If the app is not needed for first use, remove it from the ESP blocking-app list so the restart happens after the user reaches the desktop",
"If it must stay blocking, order it last in Device Setup (dependencies or install order) so a hard reboot does not interrupt the remaining apps",
"For Soft reboot apps, plan a restart at the end of enrollment or accept that the app finishes on the user's first restart"
]
}
],
"relatedDocs":[
{
"title":"Microsoft Learn: Add a Win32 app - Program step (device restart behavior and return codes)",
"summary":"{{reboot_return_code.appName}} asked for a restart",
"subject":{
"kind":"app",
"key":"{{reboot_return_code.appId}}",
"name":"{{reboot_return_code.appName}}"
},
"facts":[
{
"label":"Return code",
"value":"{{reboot_return_code.exitCode}}",
"format":"code"
},
{
"label":"Return code class",
"value":"{{reboot_return_code.value}}"
},
{
"label":"Restarts",
"value":"{{reboot_observed.count}}",
"format":"number"
}
]
},
"tags":[
"apps",
"reboot",
"return-code",
"soft-reboot",
"hard-reboot",
"esp",
"second-sign-in"
]
}
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.