Compare commits

...

472 Commits

Author SHA1 Message Date
Oliver Kieselbach a029d69d5e Session-owner binding stage 2: refuse sessions bound to another device
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>
2026-09-28 23:12:18 +02:00
Oliver Kieselbach daae1ce1fa bump pointer: internal (paged cross-partition merge released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:02:35 +02:00
Oliver Kieselbach d90cf24508 bump pointer: internal (D-294/D-295 paged cross-partition merge)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:53:35 +02:00
Oliver Kieselbach 32f1fd4132 Click-next lists change page only after the page loaded
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>
2026-09-28 20:53:35 +02:00
Oliver Kieselbach ffb09e60fa Global audit and ops-event pages continue from one RowKey watermark
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>
2026-09-28 20:53:35 +02:00
Oliver Kieselbach f920b6228e fix(web): suspended and offboarded tenants get their page again (#164) 2026-09-28 18:23:28 +02:00
Oliver Kieselbach 1765773116 bump pointer: internal (RealmJoin app install rows released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:21:38 +02:00
Oliver Kieselbach 5c7295019b bump pointer: internal (D-292/D-293 RealmJoin app install rows)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:19:18 +02:00
Oliver Kieselbach a0ad769231 RealmJoin packages count as app installs next to Intune apps
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>
2026-09-28 10:19:18 +02:00
Oliver Kieselbach 64da9f850b bump pointer: internal (D-291 chain build without downloads)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 09:07:10 +02:00
Oliver Kieselbach 5dae21968b Client-certificate chain building never downloads an issuer
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>
2026-09-28 09:06:09 +02:00
Oliver Kieselbach 59897dfbd8 bump pointer: internal (B-g9t editor verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:33:30 +02:00
Oliver Kieselbach f4551ea1ed bump pointer: internal (D-288/D-289/D-290 live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:21:58 +02:00
Oliver Kieselbach e816c92b88 Tenant configuration has one conditional write path; the full-model PUT is gone
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>
2026-09-27 20:11:56 +02:00
Oliver Kieselbach a16a70fba5 Self-service trial stays once per tenant across offboarding; plan writers decide on the fresh row
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>
2026-09-27 19:08:34 +02:00
Oliver Kieselbach 1b2bdd8f35 bump pointer: internal (SSRF gate released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 13:43:22 +02:00
Oliver Kieselbach 942bd86238 bump pointer: internal (SSRF gate live-verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 13:08:24 +02:00
Oliver Kieselbach 7a920049ca bump pointer: internal (D-287 Node/Next version plan)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:48:30 +02:00
Oliver Kieselbach 2eeb4da950 bump pointer: internal (B-h4m CodeQL note)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:38:08 +02:00
Oliver Kieselbach 20a80cbdc6 bump pointer: internal (SSRF address gate D-286, B-h4m)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:37:03 +02:00
Oliver Kieselbach c59e65ee0a fix(backend): webhook SSRF gate moves onto the connection
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>
2026-09-27 12:36:06 +02:00
Oliver Kieselbach 89aa9e1f10 fix(mcp): CIMD fetch checks the address inside the socket's own DNS lookup
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>
2026-09-27 12:35:56 +02:00
Oliver Kieselbach 2d3700cfaf bump pointer: internal (B-5p4 release state)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:16:02 +02:00
Oliver Kieselbach 5a182f3f16 bump pointer: internal (B-5p4 DO-geo deploy/verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:10:32 +02:00
Oliver Kieselbach 889b843d6d feat(agent): Delivery Optimization geo endpoint as last-resort geolocation provider
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>
2026-09-25 17:08:56 +02:00
Oliver Kieselbach 9b91119239 bump pointer: internal (way 3 docs live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:58:33 +02:00
Oliver Kieselbach 1e74d67da9 fix(web): service-principal member form names both uses — automation (access_as_application) and an app sending its users' tokens (delegated access_as_user, users read-only)
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>
2026-09-25 14:51:52 +02:00
Oliver Kieselbach ed013bbdda bump pointers: internal (hash repair via v2.0.1466), .claude/commands (platform-health repair note)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:51:37 +02:00
Oliver Kieselbach 4b40619d1d bump pointer: internal (client-app binding enforced, way 3 documented)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:48:21 +02:00
Oliver Kieselbach 3ce1f2b036 bump pointer: internal (D-285 plan live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:06:30 +02:00
Oliver Kieselbach 673f045212 fix(web): platform settings saves send only the changed fields via PATCH global/config (D-285)
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>
2026-09-25 13:55:43 +02:00
Oliver Kieselbach df640e3684 fix(backend): admin configuration is written only in its changed columns; PATCH global/config replaces the full-model PUT (D-285)
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>
2026-09-25 13:51:53 +02:00
Oliver Kieselbach 59bea9d888 bump pointers: internal (plan envelope live), .claude/commands (platform-health markers)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:11:39 +02:00
Oliver Kieselbach e2c91afda2 feat(mcp): seal refresh tokens of registered self-hosted clients so that deleting the registration also ends copies (D-283)
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>
2026-09-25 13:00:42 +02:00
Oliver Kieselbach eb47c4d69a bump pointer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:32:14 +02:00
Oliver Kieselbach 91d56f2bbb bump pointer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:58:20 +02:00
Oliver Kieselbach 12b847360c feat(web): Intune Enrollment Validation in the tenant settings; banners follow any device validation (D-282)
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>
2026-09-25 11:57:42 +02:00
Oliver Kieselbach 2e736f40ad feat(backend,shared): Intune Enrollment Validation admits enrolled devices without pre-registration (D-282)
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>
2026-09-25 11:57:26 +02:00
Oliver Kieselbach 9c3e5ecb30 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-25 06:55:36 +02:00
Oliver Kieselbach 27ce316206 bump pointer 2026-09-25 06:55:25 +02:00
Oliver Kieselbach 7280b61d28 fix(web): self-hosted AI clients are ones the organization hosts itself (#163)
"Runs on its own servers" read as on-premises only and collided with the
MCP server. The criterion is who hosts the client (on premises or in the
cloud), matching the customer docs.


Claude-Session: https://claude.ai/code/session_012P3YpD3CSsR8Sy1Ubg4pUw

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 05:43:05 +02:00
Oliver Kieselbach 79f1ecd378 Tenant Config Report: show self-hosted AI client registrations (#162)
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>
2026-09-24 20:54:10 +02:00
Oliver Kieselbach b6791d37e0 ci(codeql): exclude js/missing-rate-limiting; a test pins every OAuth route to its limiter (D-281)
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>
2026-09-24 18:58:08 +02:00
Oliver Kieselbach 4f1b4f8c03 fix(mcp): URL-encode the registration id in the tenant-client lookup path
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>
2026-09-24 18:33:32 +02:00
Oliver Kieselbach 869580c9df feat(web): progress portal deep link /progress?serial=
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>
2026-09-24 17:14:39 +02:00
Oliver Kieselbach 1a96cd20da feat(web,backend): AI Integration settings page; one self-hosted client per tenant, raisable by a Global Admin
- 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>
2026-09-24 15:00:15 +02:00
Oliver Kieselbach 8e637cf86a feat(web): docs link of the self-hosted AI clients section points at its own docs section
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:47:47 +02:00
Oliver Kieselbach 93c6e1facb feat(mcp): tenant-bound registrations for self-hosted MCP clients (D-280)
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>
2026-09-24 14:10:13 +02:00
Oliver Kieselbach 4a4bbc056a fix(mcp): the OAuth proxy advertises and returns token auth method none
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>
2026-09-24 13:35:47 +02:00
Oliver Kieselbach c59b366560 feat(auth): client-app binding for delegated tokens - measured always, enforced behind a switch (D-279)
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>
2026-09-24 13:28:21 +02:00
Oliver Kieselbach d0aded5401 chore: bump internal (D-278, MCP client plan draft)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:41:21 +02:00
Oliver Kieselbach 8f9c683b7c chore: bump infra (redirect-allowlist parameter description)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:45:42 +02:00
Oliver Kieselbach e82878af57 feat(mcp): name the rejected token's aud/azp/tid and the rejected redirect targets in the auth log lines
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>
2026-09-24 09:44:20 +02:00
Oliver Kieselbach d60e858e95 chore: bump internal (signature-check lesson)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:27:04 +02:00
Oliver Kieselbach f6ce9ae0a0 chore: bump internal (web perf audit released, first RUM comparison)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 13:38:15 +02:00
Oliver Kieselbach b4f26401f5 perf(web): stop recording own-origin non-API fetches as dependencies (D-277)
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>
2026-09-23 12:42:00 +02:00
Oliver Kieselbach 2b5004fb33 perf(web): web perf audit 2026-09-23 - cache headers, intent prefetch, seeds, SignalR refcount, lazy telemetry
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>
2026-09-23 12:10:11 +02:00
Oliver Kieselbach 00f4cb1f26 fix(agent,web): a script run duration is judged from both lines' provenance; an offset-error span is omitted, never shown (D-273)
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>
2026-09-23 09:50:49 +02:00
Oliver Kieselbach e091e50862 chore: bump internal (plan and backlog on the commit state)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 09:47:29 +02:00
Oliver Kieselbach 27ce07b111 chore: bump internal and .claude/commands (IME wait states D-271, ime-diffs note D-272)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 09:46:10 +02:00
Oliver Kieselbach f06fe5afc0 feat(agent): registry observer surfaces IME install-deadline waits and app-in-use deferrals (D-271)
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>
2026-09-23 09:46:07 +02:00
Oliver Kieselbach 37e920237c chore: bump internal (Web-Performance-Audit 2026-09-23)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 09:30:14 +02:00
Oliver Kieselbach 7279650772 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-23 08:32:03 +02:00
Oliver Kieselbach d8ab70b166 chore(rules): override fast-json-patch to 3.1.1 (Dependabot #103)
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>
2026-09-23 08:29:58 +02:00
dependabot[bot] fb8703a4c6 chore(deps): bump the actions group with 3 updates (#160)
Bumps the actions group with 3 updates: [azure/login](https://github.com/azure/login), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `azure/login` from 3.0.2 to 3.1.0
- [Release notes](https://github.com/azure/login/releases)
- [Commits](https://github.com/azure/login/compare/7ddb5af1ef8758cf1353cf3b42f940aee27ba21c...a641126d1b8aa4d1fa005f4f92df94a3a4c4c906)

Updates `github/codeql-action/init` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

---
updated-dependencies:
- dependency-name: azure/login
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 08:18:43 +02:00
Oliver Kieselbach ac3fa8b2b6 chore: bump internal (B-2am deployed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 21:29:42 +02:00
Oliver Kieselbach efcb4f9ca1 fix(offboarding): farewell feedback reaches the backend again; farewell mail leaves an ops event; suspended page instead of alert
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>
2026-09-22 21:18:44 +02:00
Oliver Kieselbach 7a7a31e8e3 chore: bump internal (B-z9d root cause)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:26:59 +02:00
Oliver Kieselbach 3fdcbabd48 chore: bump internal (backlog B-az5, B-z9d)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:11:31 +02:00
Oliver Kieselbach b2e8c7627f chore: bump internal and .claude/commands (context diet)
internal: operations facts as reference doc, leaner CLAUDE.md, context-diet plan. .claude/commands: shorter skill descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:02:27 +02:00
Oliver Kieselbach bd08d13011 chore: bump internal (mcp catalog sight check in Claude Code done)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:55:11 +02:00
Oliver Kieselbach 10c69f592e chore: bump internal (mcp-catalog-context-audit released)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 18:41:06 +02:00
Oliver Kieselbach fe0b2dc7fe refactor(mcp): catalog diet — shared argument texts, scope-first instructions, $schema stripped (D-269)
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>
2026-09-22 18:14:52 +02:00
Oliver Kieselbach a0f007fd43 fix(mcp): synthetic tenant examples, catalog budget test and audit script (D-269)
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>
2026-09-22 14:02:35 +02:00
Oliver Kieselbach 1b27babd17 ci: zizmor gate for the workflow files plus a clean baseline (D-268)
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>
2026-09-22 12:56:03 +02:00
Oliver Kieselbach 100d2242fa chore: regenerate shared manifest and wire types (TenantManagerItem.Name doc)
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>
2026-09-22 10:02:11 +02:00
Oliver Kieselbach 089b1b38fd Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-22 09:53:24 +02:00
Oliver Kieselbach ac465839a5 chore: bump internal (backlog B-umq for D-267)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:52:35 +02:00
Oliver Kieselbach ef280b7bb5 fix(backend): managed tenants see operator-provisioned access as "Platform support"
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>
2026-09-22 09:52:00 +02:00
dependabot[bot] 233737f333 chore(deps-dev): bump the web group (#159)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [autoprefixer](https://github.com/postcss/autoprefixer).


Updates `@types/node` from 22.20.2 to 22.20.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.5.6 to 10.6.1
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.6...10.6.1)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.20.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.6.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 09:29:05 +02:00
dependabot[bot] 909904e928 chore(deps): bump the mcp group (#158)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 2 updates: [zod](https://github.com/colinhacks/zod) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `zod` from 4.6.2 to 4.6.5
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.2...v4.6.5)

Updates `@types/node` from 22.20.2 to 22.20.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 09:28:50 +02:00
Oliver Kieselbach 59ab47de8c chore: bump internal (D-266 network link changes are not activity)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:48:51 +02:00
Oliver Kieselbach f252a0709f fix(agent): network link changes no longer count as enrollment activity
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>
2026-09-22 00:48:50 +02:00
Oliver Kieselbach d6d9638981 chore: bump internal (D-265 recurring health-script runs, backlog B-c6h)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:38:55 +02:00
Oliver Kieselbach 020248fbda feat(agent): collapse recurring health-script runs into one summary per policy
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>
2026-09-22 00:38:00 +02:00
Oliver Kieselbach a06a0dbfff chore: bump internal (D-264 emergency break stays a start-time check, backlog B-drn)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:08:22 +02:00
Oliver Kieselbach 96c24e0688 chore: bump internal (D-263 excessive-events recount, backlog B-7wp)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:46:03 +02:00
Oliver Kieselbach c156350183 fix(backend): excessive-events sweep warns and blocks on recounted rows
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>
2026-09-21 23:45:11 +02:00
Oliver Kieselbach 15b25ff509 chore: bump internal (D-261 typed wrapper bodies, D-262 req.Query, backlog B-t5z)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:31:15 +02:00
Oliver Kieselbach 5163d7d6a0 refactor(backend): read the request query through req.Query only
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>
2026-09-21 22:28:11 +02:00
Oliver Kieselbach 9fd343d328 fix(backend): typed bodies and error envelope for backup, session-restore and delegation wrappers
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>
2026-09-21 22:19:38 +02:00
Oliver Kieselbach aef88f3337 chore: bump internal (IME archiver pre-walk check doc, backlog B-ryg)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:59:13 +02:00
Oliver Kieselbach 12af1d6c76 fix(backend): IME archiver accepts an existing archive entry before walking the hosts
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>
2026-09-21 20:58:49 +02:00
Oliver Kieselbach 5433bff3db chore: bump internal (navbar profile photo plan and backlog)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 17:40:00 +02:00
Oliver Kieselbach 355509aa7c feat(web): show the signed-in user's profile photo in the navbar avatar
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>
2026-09-21 17:39:41 +02:00
Oliver Kieselbach 7fb78a7e29 update pointer 2026-09-21 15:31:59 +02:00
Oliver Kieselbach bfd8d58142 chore: bump internal (WNS deck plan)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:27:36 +02:00
Oliver Kieselbach 8ad472d1bc fix(agent): single-instance guard counts a same-named process only in session 0
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>
2026-09-21 15:23:23 +02:00
Oliver Kieselbach fc5e2cd751 chore: bump internal (D-259 final, SLA plan and backlog)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:10:00 +02:00
Oliver Kieselbach 9540791441 fix(web): SLA page headlines the window the alerts judge and shows "No data" for an empty period
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>
2026-09-21 15:08:38 +02:00
Oliver Kieselbach 058da1627f fix(sla): alert and dashboard judge one rolling 30-day window; an empty period is no data; a lasting breach repeats only on new enrollments
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>
2026-09-21 15:08:16 +02:00
Oliver Kieselbach cb9b3fe404 fix(agent): diagnostics paths collect exactly the folder the guard validated, and never the hard-blocked event logs
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>
2026-09-21 14:58:03 +02:00
Oliver Kieselbach 12315b2f2d fix(web): Monitoring nav is ordered by admin usage, Progress Portal moves to the end
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>
2026-09-21 13:56:38 +02:00
Oliver Kieselbach 0a4a051866 feat(web): dashboard stat cards get one descriptive icon per metric, red only when sessions failed
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>
2026-09-21 13:40:13 +02:00
Oliver Kieselbach f47d961947 fix(web): rule pages tell non-admins that contributing is admin-only instead of failing with 403
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>
2026-09-21 13:27:25 +02:00
Oliver Kieselbach c701d0d694 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-19 14:40:22 +02:00
Oliver Kieselbach d4a3edf849 fix(offboarding): a completed run removes the tenant's superseded dead letters (D-256)
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>
2026-09-19 14:09:28 +02:00
Oliver Kieselbach e164eac1eb feat(backend,web): manual maintenance trigger answers 202, the run is single-flight (D-255)
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>
2026-09-19 13:50:06 +02:00
Claude c81591b498 feat(offboarding): operator retry of a Failed tenant offboarding from Tenant Management
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
2026-09-18 10:30:06 +00:00
Oliver Kieselbach d8c03d9d95 fix(web): manual maintenance trigger waits for the run instead of timing out at 30 s
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>
2026-09-18 11:18:50 +02:00
Oliver Kieselbach ffb133f0b9 fix(backend): platform counters no longer freeze under retention (D-254)
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>
2026-09-18 10:48:49 +02:00
Oliver Kieselbach b3fe321f0d fix(offboarding): owned-group wipe no longer poisons every tenant offboarding
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>
2026-09-18 09:41:21 +02:00
dependabot[bot] 69d210c51d chore(deps): bump the mcp group (#156)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 3 updates: [compression](https://github.com/expressjs/compression), [zod](https://github.com/colinhacks/zod) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `zod` from 4.5.4 to 4.6.2
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.5.4...v4.6.2)

Updates `@types/node` from 22.20.1 to 22.20.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: zod
  dependency-version: 4.6.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 11:33:51 +02:00
dependabot[bot] 06a88e9897 chore(deps): bump the web group (#157)
Bumps the web group in /src/Web/autopilot-monitor-web with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/applicationinsights-web](https://github.com/microsoft/ApplicationInsights-JS) | `3.4.3` | `3.4.4` |
| [next](https://github.com/vercel/next.js) | `16.3.4` | `16.3.5` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.8` | `19.3.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.18` | `19.3.0` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.8` | `19.3.0` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.7` | `19.3.0` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.4` | `16.3.5` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.20.1` | `22.20.2` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.5` | `10.5.6` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.4` | `16.3.5` |


Updates `@microsoft/applicationinsights-web` from 3.4.3 to 3.4.4
- [Release notes](https://github.com/microsoft/ApplicationInsights-JS/releases)
- [Changelog](https://github.com/microsoft/ApplicationInsights-JS/blob/main/RELEASES.md)
- [Commits](https://github.com/microsoft/ApplicationInsights-JS/commits)

Updates `next` from 16.3.4 to 16.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.4...v16.3.5)

Updates `react` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom)

Updates `@types/react-dom` from 19.2.7 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@next/bundle-analyzer` from 16.3.4 to 16.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.5/packages/next-bundle-analyzer)

Updates `@types/node` from 22.20.1 to 22.20.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.7 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `autoprefixer` from 10.5.5 to 10.5.6
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.5...10.5.6)

Updates `eslint-config-next` from 16.3.4 to 16.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.5/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@microsoft/applicationinsights-web"
  dependency-version: 3.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 16.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/node"
  dependency-version: 22.20.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 11:33:35 +02:00
Oliver Kieselbach aee30f68e8 chore(submodule): tenant-management-filters plan and deploy item B-h7n
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:29:25 +02:00
Oliver Kieselbach 3db9a884de feat(web): facet filters for the Tenant Management list
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>
2026-09-15 11:28:57 +02:00
Oliver Kieselbach 0037b8cb12 chore(submodule): WDP docs push and Cloud PC automatic-mode backlog items
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 23:59:48 +02:00
Oliver Kieselbach 205b13dbb9 chore(submodule): backlog items for the ARM64 requirements docs push
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 23:24:54 +02:00
Oliver Kieselbach b938a03367 chore(submodule): backlog item for the published-DPA deploy
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:36:09 +02:00
Oliver Kieselbach d474c2be9f fix(web): privacy and terms point to the published DPA instead of "available on request"
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>
2026-09-11 23:35:59 +02:00
Oliver Kieselbach 96390beeb9 chore(submodule): drop the separate todo file from the working-file rules
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:29:23 +02:00
Oliver Kieselbach fe63a0a352 chore(submodule): park the zone-anchoring plan behind B-ugv
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 22:02:00 +02:00
Oliver Kieselbach 2e36e9c234 fix(web): Fleet Health model rows without failures link to all sessions of the model — the "Health by Device Model" deep link carried status=Failed unconditionally, so a 100 % row landed on a list that is empty by definition; the Failed filter now travels only when the bucket has failed sessions, and the link title says which list opens
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 22:00:47 +02:00
Oliver Kieselbach b83aa6d97d chore(submodule): plan for the AgentExecutor.log zone-anchoring defect
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:52:17 +02:00
Oliver Kieselbach f42fcbcb3c fix(web): the app/script panel divider names the ESP phase, not the assignment — sections read "Device Setup" / "Account Setup" from the phase timeline's table with a tooltip that device-assigned work runs under Account Setup too (a Cloud PC runs its device scripts after the user signed in), Device Preparation sessions get no divider, and user-assigned app and download rows carry a User pill from the extension's own targeting
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:48:20 +02:00
Oliver Kieselbach f70ba354f8 feat(web): additional managed tenants are add-ons — the Pro plan card reads "2 managed tenants included, additional tenants as add-ons" and a full slot count in Delegated Access suggests buying add-ons instead of asking for a larger package
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:03:51 +02:00
Oliver Kieselbach fc17047290 feat(web): plan cards — the Pro MSP line says 2 managed tenants are included and more are available on request
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 19:54:21 +02:00
Oliver Kieselbach 7bbc2cbddf feat(backend): the tenant row records the accepted DPA version — HandleNewTenantDomainAsync writes Constants.CurrentDpaVersion (2026-09-1.7) into TenantConfiguration.DpaVersion in the same first write as OnboardedBy, once, and later logins never touch it; the field is system-owned (patch deny-list, PUT preserve, revert-protected), projected by config/all, the MCP tenant list and the GA tenant report; no backfill (D-252)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 19:30:16 +02:00
Oliver Kieselbach 9a78daa880 chore(submodule): work files — D-251 settled (DPA accepted with the onboarding)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 19:05:10 +02:00
Oliver Kieselbach 5d532d3dbc feat(web): Pro is available — /buy hands off to Microsoft Marketplace and Cleverbridge (no price on the page), the plan cards show the list price of €149 / month, the self-service 30-day trial opens in the portal Plan section, and the get-started sign-in waits for a Terms of Use + DPA tick (D-251)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 18:45:56 +02:00
Oliver Kieselbach a22c26eb9d feat(web): session detail — the Download, Install and Script panels mark where the user phase begins: a quiet "Device phase" caption over the first row and a "User phase" divider before the first row that started after the device entered Account Setup, so user-targeted apps and user-context scripts stand out; sessions without a user phase render unchanged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 17:28:41 +02:00
Oliver Kieselbach b84c467255 chore(web): admin settings nav — Config Reseed and Usage Plans move up behind Diagnostics Log Paths so the MCP-related entries (Usage Plans, MCP Users, Delegated Admins, Tenant Groups) sit together
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 17:02:10 +02:00
Oliver Kieselbach 53124e94a6 feat(web): Optional Graph capabilities — tick features in the table to build the grant command (all ticked ⇒ -Features All, ScriptDisplayNames pre-ticked), Copy permissions follows the selection, and the grant card names Azure Cloud Shell as a run location
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 16:48:16 +02:00
Oliver Kieselbach 838068fd31 chore(submodule): work files for the MCP quota build (D-250) — commit hashes and the staging-list lesson
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 16:46:56 +02:00
Oliver Kieselbach 71282f3556 feat(backend,web,mcp): the MCP quota follows the delegating tenant — every request is checked and counted on the caller's home tenant, a managed tenant is never charged or blocked by its manager's reads, and every delegation slot bought beyond the edition's included ones grows both the account and the organization windows by operator-editable per-slot values (D-250)
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>
2026-09-11 16:46:27 +02:00
Oliver Kieselbach 7080dcc45a fix(backend): rule catalogs — a GitHub reseed newer than the deployed build closes the embedded self-seed (RuleCatalogStamp), the IME self-seed sunsets retired patterns, and IME pattern health reads the global partition instead of the embedded catalog (D-249)
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>
2026-09-11 12:09:18 +02:00
Oliver Kieselbach 5a62714dc2 chore(submodule): fleet baseline for the IME overwrite fix (40.7 % fallback before, 0 % after) and the P6 todo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 03:46:37 +02:00
Oliver Kieselbach c930d18f1e feat(agent,mcp): the IME log tracker's poll-loop pauses become measurable — longest pass and longest gap between passes as session maxima in agent_metrics_snapshot, ime_pattern_hits and the MCP coverage block (B-wr4)
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>
2026-09-11 03:41:43 +02:00
Oliver Kieselbach 583c81c9a8 chore(submodule): IME overwrite fix verified in the field (2.0.1460, five held results), B-pd2/B-2rs closed, B-wr4 opened
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 03:26:40 +02:00
Oliver Kieselbach 483ec8b8e0 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-11 02:55:29 +02:00
Oliver Kieselbach 328446864a chore(submodule): todo for P5 of the IME overwrite fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 02:53:02 +02:00
Oliver Kieselbach 106480ef52 fix(agent,rules): health-script lines never feed a platform-script slot — PS-SCRIPT-EXITCODE deleted, HS-INVOCATION opens an unowned invocation in the IME log, the no-marker fallback stays within files platform runs open in (D-248)
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>
2026-09-11 02:52:43 +02:00
github-actions[bot] d3a45f958d chore: regenerate rules/dist + generated TS from individual files 2026-09-11 00:29:54 +00:00
Oliver Kieselbach 6657c6b9c0 fix(rules): ANALYZE-OFFICE-001 explains what the detector actually reports
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>
2026-09-11 02:28:12 +02:00
Oliver Kieselbach 6c5e2fd921 chore(submodule): IME overwrite plan closed as RELEASED after the 2.0.1459 test-VM run
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 02:04:14 +02:00
Oliver Kieselbach 65842b87bf chore(submodule): todo for P4 of the IME overwrite fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 01:34:01 +02:00
Oliver Kieselbach b323c52364 fix(agent): a platform script's IME result waits for its executor end block before the completion is emitted; a start line older than the emitted result never opens a new run (D-247)
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>
2026-09-11 01:33:34 +02:00
Oliver Kieselbach 321a6efc81 chore(submodule): P2 of the IME overwrite fix — plan, concept doc, todo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:14:56 +02:00
Oliver Kieselbach 5ecb2a998b feat(web,mcp): the IME overwrite counters surface — session page row "Overwritten log blocks recovered"; get_session_summary coverage carries overwriteRewinds/overwriteBytesReprocessed and a recovered-not-missing note, never a gap (D-246, P2)
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>
2026-09-11 00:14:55 +02:00
Oliver Kieselbach bb6df93b51 chore(submodule): agent v2.0.1458 released
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:02:19 +02:00
Oliver Kieselbach 096a049deb chore(submodule): todo for the IME overwrite fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 23:40:58 +02:00
Oliver Kieselbach b9caf524c3 chore(submodules): concept doc, D-246, plan and skill helper for the IME multi-writer overwrite fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 23:40:21 +02:00
Oliver Kieselbach 197de1d718 fix(agent): the IME log tracker verifies and rewinds when a concurrent IME process overwrote bytes behind its bookmark; executor lines belong to the invocation at their file position (D-246)
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>
2026-09-10 23:40:20 +02:00
Oliver Kieselbach 18d4f08f2b chore(submodule): todo closed for the request-row marker (D-244)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:42:15 +02:00
Oliver Kieselbach ddf450d87d chore(submodule): plan request-row marker RELEASED (D-244)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:41:46 +02:00
Oliver Kieselbach e3b2113365 chore(submodules): import-session time shift, token, note and scrub semantics; demo import work files (D-242, D-245)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:13:11 +02:00
Oliver Kieselbach 6a85f45604 backend: agent request rows carry the admitting device validator and the proven tenant (D-244)
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>
2026-09-10 19:48:58 +02:00
Oliver Kieselbach a2d1aedcad fix(web,mcp): a script still running when observation ends is Incomplete, not running forever (D-243)
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>
2026-09-10 19:10:59 +02:00
Oliver Kieselbach e955faa97e Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-10 12:50:32 +02:00
Oliver Kieselbach 2b755bf99a fix(deps): raise sharp override to ^0.35.4 in MCP and Web (GHSA-rgj7-g3m4-5g8c)
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>
2026-09-10 12:43:27 +02:00
dependabot[bot] ce1162a055 chore(deps): bump the web-security group across 1 directory with 2 updates (#155)
Bumps the web-security group with 2 updates in the /src/Web/autopilot-monitor-web directory: [js-yaml](https://github.com/nodeca/js-yaml) and [sharp](https://github.com/lovell/sharp).


Updates `js-yaml` from 4.3.1 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2)

Updates `sharp` from 0.35.3 to 0.35.4
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.3...v0.35.4)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: indirect
  dependency-group: web-security
- dependency-name: sharp
  dependency-version: 0.35.4
  dependency-type: indirect
  dependency-group: web-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 12:10:17 +02:00
dependabot[bot] 447786b447 chore(deps): bump hono (#154)
Bumps the mcp-security group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [hono](https://github.com/honojs/hono).


Updates `hono` from 4.13.0 to 4.13.7
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.0...v4.13.7)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.7
  dependency-type: indirect
  dependency-group: mcp-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 12:01:36 +02:00
Oliver Kieselbach 2b4dbe087a fix(web,backend): MCP Usage under a tenant override shows only that tenant (D-241 amended)
- 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>
2026-09-10 03:38:58 +02:00
Oliver Kieselbach 6804bcfd4e fix(web): Reporting — Global Admin banner and tenant selector in the page header, section control row back to its old layout
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>
2026-09-10 03:27:19 +02:00
Oliver Kieselbach d2221eb0b9 feat(web,backend): MCP Usage page follows the global-admin tenant selector for its organization cards (D-241)
- 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>
2026-09-10 03:18:34 +02:00
Oliver Kieselbach 9ac6de7c7d chore: bump internal gitlink (B-fes reworded to a measurement task)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 02:43:34 +02:00
Oliver Kieselbach f5613be495 fix(web): Network Timeline — more headroom above the phase lane so "Completed (timeout)" stays inside the drawing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 02:34:51 +02:00
Oliver Kieselbach 819b6c8e4f docs(mcp): correct the query_raw_events cap figure in the payload page-size rationale (250 KB, not 500 KB); internal gitlink
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>
2026-09-10 02:33:41 +02:00
Oliver Kieselbach 9c1fb07f33 fix(web): Network Timeline — session link in portal green; Duration and Asleep tiles name the enrollment figure where the observed span differs
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>
2026-09-10 02:28:24 +02:00
Oliver Kieselbach fa31872903 fix(agent,engine,web): keep the due time as the deadline stamp; draw timeout verdicts hollow in the Network Timeline (D-238 revised)
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>
2026-09-10 01:55:31 +02:00
Oliver Kieselbach b08b79eafe test(mcp): revert_tenant_config backupId is required — schema and description pin (D-240 review)
Left out of e43d916c's explicit file list by mistake; part of the same change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 01:47:31 +02:00
Oliver Kieselbach e43d916c24 fix(mcp): spec gates, secret-safe error text, enforced response cap, payload-coupled pageSize, client cancellation (D-239, D-240)
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>
2026-09-10 01:47:10 +02:00
Oliver Kieselbach 14b185db35 fix(agent,engine,web): a deadline decision is stamped with the firing clock, never the due time (D-238)
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>
2026-09-10 01:05:16 +02:00
Oliver Kieselbach baa51102fd feat(web,backend,mcp): app rows without a terminal state become Incomplete instead of running forever (D-237)
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>
2026-09-10 00:02:24 +02:00
Oliver Kieselbach 5577f5e7e5 feat(rules): rule submissions — retention (withdrawn 30d, declined 90d), contact email, pending-first tenant list, operator hard delete (D-236 amended)
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>
2026-09-09 23:27:25 +02:00
Oliver Kieselbach 61892592e4 feat(rules): community rule submissions — submit custom rules from the portal, review via GA page or MCP, publish by repo commit + reseed (D-236)
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>
2026-09-09 22:53:32 +02:00
Oliver Kieselbach 1e2ca46946 chore: bump internal gitlink (ESP-004 released, B-kfu)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:40:23 +02:00
Oliver Kieselbach 8224c8f1a8 fix(rules,engine,agent): ESP-004 gates on the esp_apps_timeout verdict; not_exists absence gate; Shell-Core failure inherits settle detail (D-234, D-235)
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>
2026-09-09 17:20:24 +02:00
Oliver Kieselbach d7f39895f5 fix(web): omit the two default field values from the browser catalog (D-233)
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.
2026-09-09 15:26:29 +02:00
Oliver Kieselbach 790bd35385 small gather rule description change 2026-09-09 14:59:56 +02:00
Oliver Kieselbach 34c9fd42fe chore: bump internal and .claude/commands gitlinks
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).
2026-09-09 14:55:30 +02:00
Oliver Kieselbach 19a336870c fix(mcp): put the catalog pointer on the parameter, not only the tool (D-232)
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).
2026-09-09 14:55:22 +02:00
Oliver Kieselbach 5ba5942d0b feat(error-codes): parse the Entra device-registration family (D-231)
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.
2026-09-09 14:55:11 +02:00
Oliver Kieselbach aa20d9102c fix(backend): show the freshly published agent version, not the previous one (D-230)
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>
2026-09-09 12:26:03 +02:00
Oliver Kieselbach 097be1700b chore: bump internal gitlink
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:10:52 +02:00
Oliver Kieselbach f1457d823d chore: bump .claude/commands gitlink 2026-09-09 12:02:05 +02:00
Oliver Kieselbach 12e66299e6 feat(rules): allow-list folders under the signed-in user's profile (D-228, D-229)
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>
2026-09-09 11:54:36 +02:00
Oliver Kieselbach e9a01de9da chore: bump .claude/commands gitlink
Event reference in /ux-insights completed and live-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:06:20 +02:00
Oliver Kieselbach 5f793ea01a chore: bump .claude/commands gitlink
Marker-catalog rows for the What's new panel events and the digest timer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 09:58:50 +02:00
Oliver Kieselbach e86f7efed3 feat(bootstrap): verify the publisher of the extracted agent binaries before --install (D-227)
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>
2026-09-08 23:11:16 +02:00
Oliver Kieselbach 78bfad0ec6 web: the portal What's new panel names the currently published agent version
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.
2026-09-08 22:37:29 +02:00
Oliver Kieselbach f5efadfd07 ci: pin every third-party action to a commit SHA, gated and Dependabot-maintained (D-226)
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>
2026-09-08 22:14:15 +02:00
Oliver Kieselbach c26eca2f01 chore: bump internal gitlink
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:36:42 +02:00
Oliver Kieselbach 9f1879a9f3 fix(agent): raise the captured script output cap from 2K to 8K
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>
2026-09-08 21:36:15 +02:00
Oliver Kieselbach c24c7bf7ef fix(publish): serve the customer scripts as a download, not as a web page
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>
2026-09-08 21:07:35 +02:00
Oliver Kieselbach 5fac3d6004 Agent Host changes for agents/whats-new-notification-settings (#153) 2026-09-08 20:51:20 +02:00
Oliver Kieselbach 7605d2e9f5 fix(loader): log the signer CN instead of the whole EV subject
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>
2026-09-08 20:49:38 +02:00
Oliver Kieselbach 7bf5412d63 docs(mcp): teach the diag-zip map the loader log
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>
2026-09-08 20:47:56 +02:00
Oliver Kieselbach b53ceb1a7c chore: bump internal gitlink (MSI loader consolidation)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:47:01 +02:00
Oliver Kieselbach 647581b4ff refactor(msi): the MSI carries the shared stage-1 loader instead of its own downloader
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>
2026-09-08 20:41:52 +02:00
Oliver Kieselbach db9b1995bc refactor: move the error-code generator from scripts/ to tools/ (D-225)
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.
2026-09-08 20:35:51 +02:00
Oliver Kieselbach 7563435837 feat(ci): Authenticode-sign the bootstrap MSI and the runner it embeds (D-223)
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>
2026-09-08 20:34:17 +02:00
Oliver Kieselbach e39f2abb61 chore: bump internal gitlink (comment lesson)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:21:39 +02:00
Oliver Kieselbach 47dad7143f docs(ci): tighten the code-signing comments in build-agent
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>
2026-09-08 19:20:39 +02:00
Oliver Kieselbach d81f41dc72 chore: bump internal gitlink (D-223/D-224 plans and decisions)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:59:54 +02:00
Oliver Kieselbach cc6be10532 fix(publish): read the signed bytes back as byte[], not as a wrapped Object[]
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>
2026-09-08 18:54:36 +02:00
Oliver Kieselbach 0ac548bfce feat(bootstrap): two-stage loader and Authenticode-signed customer scripts (D-224)
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>
2026-09-08 18:48:21 +02:00
Oliver Kieselbach 9c1d0586ef ci: skip the provenance attestation on test runs of build-agent
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>
2026-09-08 18:34:52 +02:00
Oliver Kieselbach 21ec5e4773 ci: Authenticode-sign the agent binaries before the release hashes are computed (D-223)
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>
2026-09-08 18:20:16 +02:00
Oliver Kieselbach e4a5a6654d ci: workflows authorize Azure Storage with the federated identity, not SAS secrets (D-222)
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.
2026-09-08 17:38:14 +02:00
Oliver Kieselbach 01bc35aa97 new ordering for What's new section 2026-09-08 15:48:28 +02:00
Oliver Kieselbach e33d85c01d Agent Host changes for agents/gather-rule-exitcode-info-update (#151) 2026-09-08 14:57:30 +02:00
Oliver Kieselbach 4e73ba8900 Agent Host changes for agents/improve-changelog-promotion-ui (#152) 2026-09-08 14:57:11 +02:00
Oliver Kieselbach 4df52b3da9 Agent Host changes for agents/timeline-search-enhancement (#150) 2026-09-08 14:47:30 +02:00
Oliver Kieselbach 844d86410c Agent Host changes for agents/mcp-server-beschreibung-lieferung (#149) 2026-09-08 14:46:15 +02:00
dependabot[bot] 9514e1033d chore(deps): bump the web group (#148)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.3.3` | `16.3.4` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.3` | `16.3.4` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.5` | `19.2.7` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.4` | `10.5.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.4` |


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

Updates `@next/bundle-analyzer` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/next-bundle-analyzer)

Updates `@types/react-dom` from 19.2.5 to 19.2.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `autoprefixer` from 10.5.4 to 10.5.5
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.4...10.5.5)

Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 13:07:51 +02:00
Oliver Kieselbach b2236bf65f web: card and page headers stack below sm so their action buttons stay on screen at phone width
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>
2026-09-08 01:35:50 +02:00
Oliver Kieselbach 31535b07e0 web+backend: a warming MCP health card says only "Starting instance... (attempt n of 12)" and re-probes for ~60s before it warns (D-220)
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.
2026-09-07 21:27:07 +02:00
Oliver Kieselbach 066b6e6f04 backend+web: the MCP health probe answers a cold container in 3s with status "warming" instead of holding the request for 30s, and the System Health card re-probes until it is up (D-220)
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>
2026-09-07 18:25:56 +02:00
Oliver Kieselbach a1e2b76a9f web: the session Vulnerability Report names its inventory badge "Identification Confidence" (header and legend, Inventory and Delta tab); the Software Inventory analyzer toggle uses the rose accent like the other Agent Analyzer toggles
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>
2026-09-07 13:13:31 +02:00
Oliver Kieselbach 29e5b8251d web(rules): analyze and gather rule cards get a two-row header on every breakpoint — badge row with fixed-width toggle, severity and rule-id columns on top, the full title below with threshold, sparkline and hit rate right-aligned; gather badges ordered category, type, collector like the analyze card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:11:44 +02:00
Oliver Kieselbach 02168a5c55 chore: bump internal gitlink (geo-statistics-explained plan, D-219, backlog B-fpu/B-csp)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:12:12 +02:00
Oliver Kieselbach a6b6661320 web(geo): the Location Performance table states what Avg Duration, P95 and vs Global are computed over (footnote with docs links, header and benchmark tooltips); a location without a succeeded session shows no duration instead of 0 min / -100% in the fastest bucket
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:10:25 +02:00
Oliver Kieselbach 0c72666a7a chore: bump skills gitlink (CAS marker row corrected to the pre-fix code path)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:35:34 +02:00
Oliver Kieselbach 9023321db9 chore: bump skills gitlink (platform-health KQL fix + 09-07 marker baselines)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:48:08 +02:00
Oliver Kieselbach 7ef097529c backend: deleting an owned tenant group stamps the retention grace anchor on every member (conferred Pro ended without ProDowngradedUtc)
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>
2026-09-07 00:15:21 +02:00
Oliver Kieselbach e7df69efe4 backend+web: vulnerability summary carries a priority breakdown (act/attend/track); the exposure panel shows Act and Attend tiles and every severity or priority chip filters the top-CVE list (D-218)
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>
2026-09-06 22:30:36 +02:00
Oliver Kieselbach 2c7efa2733 backend: key-safe X-MCP-Tool-Name prefix for the usage row key (CodeQL cs/log-forging #91)
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>
2026-09-06 22:19:56 +02:00
Oliver Kieselbach 2742549827 chore: bump skills gitlink (platform-health marker baselines + GetBootstrapScript trap)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:07:46 +02:00
Oliver Kieselbach 3c45f5cb09 chore: regenerate the shared manifest and wire types for the NtpServer summary text (D-216 follow-up)
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>
2026-09-06 22:02:10 +02:00
Oliver Kieselbach d5d1d7bfbd backend+web: one agent-config derivation (AgentConfigResolver) served on agent/config and GET config/{tenantId}/effective-agent-config; wire-type initializer defaults in the shared manifest; the Tenant Config Report reads both (D-216)
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>
2026-09-06 21:59:34 +02:00
Oliver Kieselbach f4e2088b69 chore: bump internal gitlink (lessons: shared index) 2026-09-06 21:57:49 +02:00
Oliver Kieselbach 55425058a8 chore: bump internal gitlink (dashboard-search plan review) 2026-09-06 21:57:26 +02:00
Oliver Kieselbach eec0d17607 chore: bump internal + skills gitlinks (D-217 plan/review, lessons, ux-insights marker row) 2026-09-06 21:54:47 +02:00
Oliver Kieselbach 43f5304ba4 web+backend: dashboard search gets field qualifiers (model=, manufacturer=), AND-ed terms and exclusions, parsed identically on both sides; Fleet Health model rows link with that search (D-217)
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.
2026-09-06 21:54:10 +02:00
Oliver Kieselbach b21472dd8c web(progress): pre-provisioned device parked after the technician part sits on Account setup instead of a forever-running device apps step
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>
2026-09-06 21:24:32 +02:00
Oliver Kieselbach e3991d55be web(admin): Tenant Config Report renders from an exhaustive catalog over the generated wire types (D-215)
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>
2026-09-06 21:08:03 +02:00
Oliver Kieselbach 864d1ed31e backend: MCP usage counters go through TableCasRetry with an 8-attempt jittered budget instead of three immediate retries (B-c92)
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.
2026-09-06 19:04:58 +02:00
Oliver Kieselbach 76ef1e53ca chore: bump internal gitlink (final check 2026-09-06: plans released, backlog long-tail rewritten, todo cleared) 2026-09-06 18:48:46 +02:00
Oliver Kieselbach 7dd298f04e chore: bump skills gitlink (session skills: exit-code class, packaging counters, WU symbols)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:21:14 +02:00
Oliver Kieselbach 66452621c6 exit-code class per app + reboot rule: IME-EXITCODE-CLASS, ANALYZE-APP-018, exitCode on every terminal app event (D-214)
- 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>
2026-09-06 18:08:49 +02:00
Oliver Kieselbach 0b23d93957 updated last modified 2026-09-06 17:49:25 +02:00
Oliver Kieselbach 4281a6d81b chore: bump internal gitlink (rules category groups plan, Paket 3) 2026-09-06 17:39:10 +02:00
Oliver Kieselbach fe919a51aa web rules pages: group analyze and gather rules by category with collapsible headers, counters and Expand/Collapse All (state per tab in localStorage); reveal a rule inside its group from telemetry, template copy and view-copy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:37:44 +02:00
Oliver Kieselbach 0dcf0eb503 Observation coverage in get_session_summary, absence audit with coverage lint, diagnostics packaging counters (D-213)
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>
2026-09-06 17:35:52 +02:00
Oliver Kieselbach e2fb99d5d8 mcp access-guard: surface the suspension text from the policy envelope; AdminConfiguration.McpAccessPolicy comment matches the enforced AllMembers semantics; internal gitlink (MCP-Nebenbefunde, B-4s2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:12:19 +02:00
Oliver Kieselbach a3ee613b60 scripts/error-codes: decode entities in one pass and strip tags until none remain (CodeQL 89/90)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:33:36 +02:00
Oliver Kieselbach d2197c6a57 error-code catalog v2: generated from primary sources, symbols and families, MCP lookup_error_code (D-211/D-212)
Shared: error-codes.json schemaVersion 2 (607 entries, 50 IME enforcement states) generated
by scripts/error-codes/generate.mjs from MS Learn tables and the IME enums of one build;
ErrorCodeEntry gains symbol/category/imeRetriesDuringEsp, ErrorCodeCatalog gains
TryLookupDetailed (HRESULT_FROM_WIN32 derivation as data), TryLookupBySymbol,
TryLookupEnforcementState. Backend enricher siblings carry symbol/category/derivedFromWin32
plus enforcementStateInfo. Web: validating, minifying sync; formatErrorCodeSource for every
tooltip; windows_update_failed badge; script error codes decoded; bundle budget test.
Agent: WindowsUpdateTracker reads the catalog symbol (WU_E_UNKNOWN sentinel, load failure
never costs an event). MCP: error-code-catalog.ts (lexical-only), lookup_error_code for every
role, search_knowledge merges catalog hits, get_session_summary key events carry
errorCode/errorText, Dockerfile COPY + ERROR_CODES_PATH. ANALYZE-APP-014 1.2.1 corrects the
0x80073cf0 gloss. Gitlinks: internal (D-211/D-212, plan review, backlog), .claude/commands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:24:35 +02:00
Oliver Kieselbach e5997bc50e chore: bump internal gitlink (error-code catalog v2 plan)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 15:25:13 +02:00
Oliver Kieselbach 75138016ce chore: bump internal gitlink (analysis notes revision 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:46:44 +02:00
Oliver Kieselbach fc4772efb0 chore: bump internal gitlink (analysis notes: detection-coverage ideas)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:42:56 +02:00
Oliver Kieselbach 0bde428fa0 web: GA tenant modal — MCP switch and suspension moved down next to offboarding
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>
2026-09-06 14:13:40 +02:00
Oliver Kieselbach 16ca3a059c backend/web/mcp: per-tenant MCP switch as an operator control (D-210)
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>
2026-09-06 14:01:01 +02:00
Oliver Kieselbach 1bfc80e6db chore: bump internal gitlink (release backfill plan RELEASED, D-209)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 13:26:49 +02:00
Oliver Kieselbach fa705a2ef6 updated Readme.md 2026-09-06 13:13:42 +02:00
Oliver Kieselbach 81f807e124 web: modal overlays render through ModalPortal into <body> (D-208)
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>
2026-09-06 12:27:01 +02:00
Oliver Kieselbach a4f7c88785 chore: bump internal gitlink (request-wire-contract RELEASED, live verify) 2026-09-06 12:02:36 +02:00
Oliver Kieselbach 54259f070f chore: bump internal gitlink (request-wire-contract GEBAUT, D-207, B-r95/B-cme) 2026-09-06 11:42:37 +02:00
Oliver Kieselbach 26cef3ab06 web: request bodies as jsonBody<T> against the generated wire types (D-207)
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>
2026-09-06 11:42:24 +02:00
Oliver Kieselbach 00ad230ffe mcp: request bodies typed against the generated wire types (jsonBody<T>, D-207)
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>
2026-09-06 11:42:22 +02:00
Oliver Kieselbach 3d920a8113 shared+backend: IApiRequest marker, one RequestBody reader, every request body a Shared wire type (D-207)
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>
2026-09-06 11:42:03 +02:00
Oliver Kieselbach f6eac1964a chore: bump internal gitlink (both plans RELEASED, live verify, B-fq7 / B-8va)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 04:10:34 +02:00
Oliver Kieselbach e1b5b9d163 backend+web: per-user rate limits split by throttle surface — portal (public client) gets its own global budget, MCP/integrations keep override and floor (D-206)
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>
2026-09-06 03:45:42 +02:00
Oliver Kieselbach 094833f831 backend: a Graph 401/403 on the first validator attempt invalidates the tenant token and retries fresh; the agent keeps getting 503 for a permission gap (D-205)
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>
2026-09-06 03:35:39 +02:00
Oliver Kieselbach 3579ce0add chore: bump internal gitlink (typed call sites RELEASED, verify) 2026-09-06 02:55:20 +02:00
Oliver Kieselbach 0188860d1c chore: bump internal gitlink (storage-budgets plan RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:52:23 +02:00
Oliver Kieselbach 871a1ac059 chore: bump internal (typed call sites plan GEBAUT, D-204, envelope doc consumers, B-7sw) and .claude/commands (mcp-telemetry retries) gitlinks 2026-09-06 02:37:21 +02:00
Oliver Kieselbach 298a182fbb shared+web: backup job status and restore-row payloads join the wire contract; the web reads the generated types (audit package 5, stage 4b)
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>
2026-09-06 02:34:11 +02:00
Oliver Kieselbach e71223507c web: customs archive reads the generated types, annotations page follows nextLink through the typed builder, dead PaginatedResponse removed (audit package 5, stage 4a)
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>
2026-09-06 02:28:08 +02:00
Oliver Kieselbach 02187f0f3c web: the last raw API call sites join the call layer; ratchet baseline for authenticatedFetch reaches zero (audit package 5, stage 3d)
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>
2026-09-06 02:21:25 +02:00
Oliver Kieselbach 3e2f62daeb backend: host.json holds Azure.Identity, Microsoft.AspNetCore and Microsoft.Hosting.Lifetime at Warning
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>
2026-09-06 02:11:44 +02:00
Oliver Kieselbach c8b8a8dbb4 web(sessions/dashboard/progress): every API call through fetchJson/fetchOk/fetchBlob (audit package 5, stage 3c)
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>
2026-09-06 02:09:51 +02:00
Oliver Kieselbach 9e4c5f11c5 chore: bump internal gitlink (plan verify line, trace-noise backlog idea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:00:11 +02:00
Oliver Kieselbach b16e72c10e web(settings): every API call through fetchJson/fetchOk, errors through apiErrorText (audit package 5, stage 3b)
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>
2026-09-06 01:56:53 +02:00
Oliver Kieselbach 5cb9b0b30b backend: storage SDK budgets under the agent timeout, ingest 503 on an exhausted budget, request-token fan-outs, single-flight factory budget, ProcessStall monitor (audit F09, D-203)
- 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>
2026-09-06 01:51:02 +02:00
Oliver Kieselbach c925001a8a web(admin): every API call through fetchJson/fetchOk/fetchBlob, errors through apiErrorText (audit package 5, stage 3a)
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>
2026-09-06 01:41:19 +02:00
Oliver Kieselbach e494334720 mcp: apiFetch<T> at every call site, one retry on 429/503 within Retry-After, typed error envelope (audit package 5, stage 2)
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>
2026-09-06 01:13:20 +02:00
Oliver Kieselbach 1554540431 web: one API call layer (fetchJson/fetchOk/fetchBlob), notifyError, ratchets with the full baseline (audit package 5, stage 1)
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>
2026-09-06 01:01:41 +02:00
Oliver Kieselbach fef09690d2 web: coalesce live timeline refetches per burst instead of per SignalR signal
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>
2026-09-06 00:41:13 +02:00
Oliver Kieselbach 3a975fa74f chore: bump internal gitlink (wire-contract plan RELEASED, short-sentence lesson) 2026-09-05 23:47:00 +02:00
Oliver Kieselbach 69c6353fb1 chore: bump internal (plan gebaut, D-200/D-201/D-202, drift-guards) and .claude/commands (platform-health 422 marker) gitlinks 2026-09-05 23:28:16 +02:00
Oliver Kieselbach 2b9bf3cd3f backend: one query-parameter parser (QueryParams) with a lenient and a strict contract, ratcheted (D-202)
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>
2026-09-05 23:26:21 +02:00
Oliver Kieselbach d99e16314b agent+backend: one telemetry wire class, 422 poison for rejected items, agent responses in the typed regime (D-200/D-201)
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>
2026-09-05 23:10:25 +02:00
Oliver Kieselbach e4203e0f15 chore: bump .claude/commands (platform-health skill, backend-logs corrections) and internal (marker-catalog rule) gitlinks; drop stale index dual-write mention in the dependency filter comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:53:14 +02:00
Oliver Kieselbach 2a5366d9c3 chore: bump internal gitlink (D-198/D-199, counters concept doc, storage-hardening plan GEBAUT)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:31:33 +02:00
Oliver Kieselbach 9c9cf24ddc backend: platform counters recomputed instead of incremented, RuleStats keyed per scope and day with CAS batches (audit F06, D-198/D-199)
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>
2026-09-05 22:30:58 +02:00
Oliver Kieselbach ec0d8ad735 backend: blob proxy lifetime and timing, one delegation key per process, shared KEV instance, single-flight config misses (audit F11/F12)
- 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>
2026-09-05 22:14:56 +02:00
Oliver Kieselbach fc9e9d13e6 backend: keep slow storage successes and count CAS conflicts as a metric (audit F10)
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>
2026-09-05 22:09:08 +02:00
Oliver Kieselbach d0cb7e66f4 backend: byte-aware table transactions and classified storage failures on the ingest (audit F07)
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>
2026-09-05 22:03:07 +02:00
Oliver Kieselbach bd66480ccc chore: bump internal gitlink (remove-decision-inspector RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 05:05:36 +02:00
Oliver Kieselbach b05f1f6bcf chore: bump internal gitlink (remove-v2-index-tables RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:51:52 +02:00
Oliver Kieselbach 12ad9c9436 Remove the Decision Inspector: web subpage, three session read endpoints, renderer deps (D-197)
Web: app/sessions/inspector, Inspector button, inspectorUrl, legacy /sessions/{id}/inspector
redirect, SWA rewrites, api.inspector; drops @xyflow/react, dagre, @types/dagre.
Backend/Shared: GetSessionSignals/DecisionGraph/ReducerVerification functions, DecisionGraphBuilder,
ReducerVerifier, their DTOs and tests, the QueryBySessionAsync readers and SignalQueryLimits;
three catalog entries fewer. Signals/DecisionTransitions tables and ingest unchanged; operator
analysis reads them through MCP. Wire manifests regenerated (web + MCP copy).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:51:28 +02:00
Oliver Kieselbach c8ce7cdb37 backend: remove the V2 index tables and their dual-write pipeline (D-196)
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>
2026-09-05 04:27:48 +02:00
Oliver Kieselbach 56d4dd8b84 chore: bump internal gitlink (queue-trigger plan RELEASED, live verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 03:16:26 +02:00
Oliver Kieselbach 86fdc5aca9 backend: stateless queue consumers become [QueueTrigger] functions; webhooks go through a queue; poll loop receives one message at a time
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>
2026-09-05 02:52:37 +02:00
Oliver Kieselbach 87c5bc59dc chore: bump internal gitlink (error envelope RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:57:06 +02:00
Oliver Kieselbach 21b30b21c2 chore: bump internal gitlink (lessons rule block)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:44:39 +02:00
Oliver Kieselbach f94f1c0c4e chore: bump internal gitlink (lesson: no Front Door for the API)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:44:02 +02:00
Oliver Kieselbach a6a9883451 chore: bump internal gitlink (error envelope + correlation docs, D-192/D-193)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:37:57 +02:00
Oliver Kieselbach 03116a97d1 mcp: one correlation id per tool call — sent to the backend, logged, shown in every error
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>
2026-09-05 01:33:44 +02:00
Oliver Kieselbach 2abc78ab6d web: send X-Correlation-ID on every API call, read the error envelope, show the reference
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>
2026-09-05 01:29:54 +02:00
Oliver Kieselbach 6e54b52376 backend: migrate the 458 anonymous error bodies to the typed envelope helpers
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>
2026-09-05 01:21:01 +02:00
Oliver Kieselbach 7cef2ea361 backend: one typed error envelope (IApiErrorResponse) on every non-2xx body
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>
2026-09-05 00:56:21 +02:00
Oliver Kieselbach 5b5ee0e8fd chore: bump internal gitlink (release verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:43:17 +02:00
Oliver Kieselbach 7bcc4f00b1 chore: bump internal gitlink (release verify 2026-09-04)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:42:33 +02:00
Oliver Kieselbach 34ec44e3d4 backend: duration evaluators refuse mixed timestamp provenance only
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>
2026-09-04 21:34:15 +02:00
Oliver Kieselbach f607e21c13 agent+rules: Hybrid Join user affinity from the IME user token; IME backlog eras anchored by the bootstrap record
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>
2026-09-04 21:12:56 +02:00
Oliver Kieselbach 5940e9fc6a auth: application principals — app-only tokens flow as app:<client-id> members (B-nuw)
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>
2026-09-04 19:17:34 +02:00
Oliver Kieselbach 98cfd76232 plans: Pro (MSP) — a permanent-Pro managing tenant confers Pro on its managed tenants, derived at read time (B-qvu)
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>
2026-09-04 18:31:48 +02:00
Oliver Kieselbach 09e68a614f web(geo): legend buckets under the Performance Map toggle which markers are shown
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>
2026-09-04 16:32:45 +02:00
Oliver Kieselbach 88290caadc security: sanitize method and decoded path in the privileged-denial record
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>
2026-09-04 16:25:52 +02:00
Oliver Kieselbach f442dce9a4 logs proxy: a rejected query is the caller's 400, so the MCP shows the store's error in full
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>
2026-09-04 16:14:36 +02:00
Oliver Kieselbach 4f03f39e9a chore: bump internal gitlink (q8n post-verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:11:07 +02:00
Oliver Kieselbach 38b667799c ci: workflow-only commits no longer trigger CI and CodeQL
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>
2026-09-04 16:05:21 +02:00
Oliver Kieselbach a5f74d6abc ci(agent): release body no longer repeats the release title
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:01:52 +02:00
Oliver Kieselbach d0e09301a1 ci(agent): release body drops the Installation and Included Components sections
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:59:49 +02:00
Oliver Kieselbach 177915ea6b chore: bump gitlinks (agent-release approval gate, plan released with v2.0.1448)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:58:39 +02:00
Oliver Kieselbach b89008e745 ci(agent): customer release notes come in through the release_notes dispatch input, never from CI (D-187)
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>
2026-09-04 15:47:10 +02:00
Oliver Kieselbach cfa1cbbed2 security: cap the privileged-denial alarm so a valid token cannot flood the operator
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>
2026-09-04 14:54:50 +02:00
Oliver Kieselbach aa1881ec44 chore: bump internal gitlink (backlog ids B-xxx)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:48:25 +02:00
Oliver Kieselbach 744e35ee9b mcp: get_deployment_state — live vs verified-deployed for backend, web, mcp and agent (x29 phase 3)
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>
2026-09-04 14:07:03 +02:00
Oliver Kieselbach 884b74cb8c reports: ticket-gated download of session-report ZIPs for the MCP (x29 phase 2)
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>
2026-09-04 14:01:52 +02:00
Oliver Kieselbach 67316a0fbd chore: bump internal gitlink (q8n lessons)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:50:51 +02:00
Oliver Kieselbach 501f994c7a logs proxy: three telemetry stores, a wall-clock budget and typed partial results (x29 phase 1)
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>
2026-09-04 13:50:19 +02:00
Oliver Kieselbach c38f0cf2b0 fix(agent): keep-awake releases on the user-ESP page exit, not the synthesized completion; guard change reverted (q8n)
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>
2026-09-04 13:48:40 +02:00
Oliver Kieselbach 729ee56bde security: assume-breach alarm for the Global-Admin-only surface (x29 phase 0)
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>
2026-09-04 13:29:06 +02:00
Oliver Kieselbach f0a3f04398 fix(agent,rules): AccountSetup activity means observed progress; same-event filter for event_data conditions (q8n)
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>
2026-09-04 13:11:28 +02:00
Oliver Kieselbach 49013c3cc4 chore: bump internal gitlink (cockpit path links)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:59:27 +02:00
Oliver Kieselbach 32cbc3620e chore: bump internal gitlink (cockpit reader view)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:53:30 +02:00
Oliver Kieselbach ae9c1b0744 chore: bump internal gitlink (cockpit edit)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:42:17 +02:00
Oliver Kieselbach 9eb8cac507 ci: skip CI and CodeQL on gitlink bumps and prose-only commits
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>
2026-09-04 05:00:25 +02:00
Oliver Kieselbach 69ed763640 chore: bump internal gitlink (cockpit stats tiles) 2026-09-04 04:51:53 +02:00
Oliver Kieselbach 369650aeeb chore: bump internal gitlink (cockpit chrome) 2026-09-04 04:47:15 +02:00
Oliver Kieselbach 0280366cc3 chore: bump internal gitlink (lesson) 2026-09-04 04:36:38 +02:00
Oliver Kieselbach ac2b52cc4d chore: bump internal gitlink (todo cleared) 2026-09-04 04:36:09 +02:00
Oliver Kieselbach 8faa304c94 chore: trim public CLAUDE.md to the public half; bump internal gitlink 2026-09-04 04:32:57 +02:00
Oliver Kieselbach 95817f1296 chore: bump internal gitlink (cockpit live file view) 2026-09-04 04:30:24 +02:00
Oliver Kieselbach e6981bad99 chore: bump internal gitlink (cockpit browse) 2026-09-04 04:26:14 +02:00
Oliver Kieselbach 99717f03e2 chore: bump internal gitlink (cockpit file view) 2026-09-04 04:18:06 +02:00
Oliver Kieselbach 04f53b882c chore: bump internal gitlink (cockpit date view) 2026-09-04 04:10:38 +02:00
Oliver Kieselbach 558153f29c chore: bump internal gitlink (cockpit decisions view) 2026-09-04 04:07:50 +02:00
Oliver Kieselbach 91ba9d24a1 chore: bump internal gitlink 2026-09-04 03:54:12 +02:00
Oliver Kieselbach e8ecb5537c chore: bump internal gitlink (backlog ids) 2026-09-04 03:54:02 +02:00
Oliver Kieselbach 6e4563b2a0 chore: bump internal gitlink (backlog cockpit) 2026-09-04 03:31:47 +02:00
Oliver Kieselbach 435aef867c CLAUDE: public half keeps directives, knowledge map, hard rules and code quality; process, working files, OKF, customer-claims detail live in the private half
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 03:15:26 +02:00
Oliver Kieselbach e76f312adb chore: bump internal gitlink (rules moved into the private instruction half) 2026-09-04 03:13:44 +02:00
Oliver Kieselbach eac470e781 chore: bump internal gitlink (lesson) 2026-09-04 03:12:51 +02:00
Oliver Kieselbach 685a86215b chore: bump skills and internal gitlinks (skills rework 20 -> 16, MCP-first) 2026-09-04 03:12:28 +02:00
Oliver Kieselbach 8a259241b5 chore: operator scripts leave the public tree (versioned in the private infra submodule); scripts/ keeps only Bootstrap and CustomerSetup 2026-09-04 02:43:23 +02:00
Oliver Kieselbach fd41dded3d chore: bump internal gitlink (private instructions: operations facts) 2026-09-04 02:23:47 +02:00
Oliver Kieselbach db020789e1 chore: private submodule docs/ becomes internal/ (knowledge + decisions + work files + private instructions)
- .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
2026-09-04 02:19:00 +02:00
Oliver Kieselbach 186c151046 fix(web): tenant filter resolves a typed domain name, session search matches tenant domain and ID
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>
2026-09-03 18:20:43 +02:00
Oliver Kieselbach c4c253a03a feat(app-homing): name the add-on roles that block the self-service switch and hand the admin the fix
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>
2026-09-03 17:08:03 +02:00
Oliver Kieselbach f833882e07 fix(ops): AgentEmergencyBreak ops event severity follows session state at break time
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>
2026-09-03 16:03:47 +02:00
Oliver Kieselbach 0505e5d5bd feat(web): switchable color modes and legend on the geographic performance map
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>
2026-09-03 15:56:07 +02:00
Oliver Kieselbach a4140c4e4c docs(readme): refresh screenshots, add social preview source
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>
2026-09-03 14:50:19 +02:00
Oliver Kieselbach 0e928b5506 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-03 14:29:28 +02:00
Oliver Kieselbach ce9efb6f6c docs(repo): AGPL-3.0 root license, MCP section in README, security policy and issue forms
- 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>
2026-09-03 14:29:14 +02:00
dependabot[bot] 73961f67f2 build(deps-dev): bump @humanfs/node (#146)
Bumps the web-security group with 1 update in the /src/Web/autopilot-monitor-web directory: [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node).


Updates `@humanfs/node` from 0.16.7 to 0.16.8
- [Release notes](https://github.com/humanwhocodes/humanfs/releases)
- [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node)

---
updated-dependencies:
- dependency-name: "@humanfs/node"
  dependency-version: 0.16.8
  dependency-type: indirect
  dependency-group: web-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 13:27:42 +02:00
dependabot[bot] 48fe68b87c build(deps): bump fast-uri in /src/McpServer/autopilot-monitor-mcp (#143)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 13:15:00 +02:00
dependabot[bot] f1820b2b43 build(deps): bump qs in /src/McpServer/autopilot-monitor-mcp (#144)
Bumps [qs](https://github.com/ljharb/qs) from 6.15.2 to 6.16.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.2...v6.16.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 13:14:46 +02:00
dependabot[bot] 78f810a326 build(deps): bump the mcp group (#145)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 2 updates: [zod](https://github.com/colinhacks/zod) and [tsx](https://github.com/privatenumber/tsx).


Updates `zod` from 4.5.2 to 4.5.4
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.5.2...v4.5.4)

Updates `tsx` from 4.23.12 to 4.23.13
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.12...v4.23.13)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: tsx
  dependency-version: 4.23.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 13:14:29 +02:00
Oliver Kieselbach 9207b066db changed dependabot to batching 2026-09-03 13:06:38 +02:00
Oliver Kieselbach 0d41f50ace feat(web): answer-engine optimization — problem-framed FAQ on /about, entity graph JSON-LD, llms.txt, AI crawlers in robots
- /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>
2026-09-03 00:41:06 +02:00
Oliver Kieselbach b294d2025d feat(plans): tenant-wide MCP usage-plan override + Community upgrade hint on quota 429
- 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>
2026-09-02 23:51:48 +02:00
Oliver Kieselbach 12a75ef13d feat(delegation): MCP quota follows the managed tenant, delegated tenant slots, self-service delegation
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>
2026-09-02 21:37:36 +02:00
Oliver Kieselbach 5494fd1862 fix(web): re-home hop peeks the post-login deep link instead of re-saving it
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>
2026-09-02 17:26:28 +02:00
Oliver Kieselbach 6c6d746532 feat(web): small "Docs" link in every portal page header
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>
2026-09-02 16:34:37 +02:00
Oliver Kieselbach 7563ec9158 feat(web): DocsLink shows a one-word label on narrow screens
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:18:45 +02:00
Oliver Kieselbach 4e1a3c6312 feat(auth): prepare the new app registration as the default sign-in app
- 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>
2026-09-02 16:18:45 +02:00
Oliver Kieselbach 3eb1f39c4b feat(web): bulk actions capped at 100 sessions, deletes above 10 require typing DELETE
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>
2026-09-02 15:26:14 +02:00
Oliver Kieselbach 7e82decc01 feat(plan): company name in the tenant contact profile, required with the contact address at the Pro trial entry point
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>
2026-09-02 12:52:08 +02:00
Oliver Kieselbach af7850d786 Revert "fix(web): MCP Usage page tolerates a backend without the tenant quota fields"
This reverts commit 62c7a29b46.
2026-09-02 12:00:56 +02:00
Oliver Kieselbach 62c7a29b46 fix(web): MCP Usage page tolerates a backend without the tenant quota fields
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>
2026-09-02 11:58:33 +02:00
Oliver Kieselbach 78a043db08 feat(web): shared SectionCardHeader with docs links in every settings section
- 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>
2026-09-02 11:02:45 +02:00
Oliver Kieselbach ec3f0ce237 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-02 10:35:01 +02:00
Oliver Kieselbach eb47dfb6ce feat(mcp): organization-wide MCP quota next to the per-user quota
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>
2026-09-02 10:32:15 +02:00
dependabot[bot] 7a2465fecf build(deps-dev): bump postcss-selector-parser (#142)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 6.1.2 to 6.1.4.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/v6.1.2...6.1.4)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 6.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 10:26:09 +02:00
Oliver Kieselbach 4b8b2a5a6d fix(mcp): AllMembers grants tenant members only, not every authenticated token; per-user overrides stay visible
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>
2026-09-02 10:03:10 +02:00
Oliver Kieselbach 4521e248b3 test(agent): V2 Core scenario budgets become one 30 s deadlock guard
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>
2026-09-02 04:30:41 +02:00
Oliver Kieselbach 4c32d66a63 ci: print failed-test messages in the .NET test steps
`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>
2026-09-02 04:22:04 +02:00
Oliver Kieselbach a635ac3bc6 feat(health): operator-only cards and URLs follow the Global-Admin view; stacked URL rows
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>
2026-09-02 03:57:10 +02:00
Oliver Kieselbach d59286b8b3 chore(mcp): keep X-MCP-Pretty out of the repo's own client config
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>
2026-09-02 03:33:51 +02:00
Oliver Kieselbach 05641d4076 feat(mcp): per-client pretty-printed results via the X-MCP-Pretty header
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>
2026-09-02 03:30:50 +02:00
Oliver Kieselbach 7288220341 perf(mcp): smaller results and parallel type walks (perf audit P4)
- 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>
2026-09-02 03:13:17 +02:00
Oliver Kieselbach ea7b850ef0 perf(mcp): lean event projection only on unfiltered reads; filtered reads stay complete
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>
2026-09-02 02:59:38 +02:00
Oliver Kieselbach e85dd082d6 perf: read caches, cross-tenant fan-out and lean MCP results (2026-09 perf audit, P1)
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>
2026-09-02 02:55:18 +02:00
Oliver Kieselbach 034aaa03c8 feat(web): dashboard Select mode for bulk session delete / device block
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>
2026-09-02 01:42:11 +02:00
Oliver Kieselbach 9b6b03dac5 fix(backend): verdict radar carries legacy:{rule} into the sweep:{rule} baseline; derive ESP-message rows as esp_failure_fallback
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>
2026-09-02 01:01:49 +02:00
Oliver Kieselbach fd6f359e1c update wording 2026-09-02 00:54:22 +02:00
Oliver Kieselbach e99a7d67c0 test(agent): ScenarioTests join the SerialThreading collection
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>
2026-09-02 00:52:56 +02:00
Oliver Kieselbach 45a1620f6b chore: remove root build.ps1
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>
2026-09-02 00:20:07 +02:00
Oliver Kieselbach 1955efdae5 test: replace the operator's own tenant/device identities with synthetic values
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>
2026-09-02 00:13:59 +02:00
Oliver Kieselbach a5c42de28c chore: move infra/ into a private submodule
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>
2026-09-02 00:06:38 +02:00
Oliver Kieselbach 19c4354224 docs: README assets move to .github/assets, drop the architecture link
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>
2026-09-02 00:02:40 +02:00
Oliver Kieselbach 8e439176fd chore: move docs/ into a private submodule, drop the change log
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>
2026-09-01 23:53:59 +02:00
Oliver Kieselbach 73c0fbdd7e docs: device identity block binding, session-scoped blocks, progress lookup, validator budget
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:37:43 +02:00
Oliver Kieselbach bffb76625c perf(backend): time budget for the Graph device validators
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>
2026-09-01 23:37:42 +02:00
Oliver Kieselbach d8ef8b11b0 fix(backend): progress-portal lookup resolves exact serial/device name via index queries
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>
2026-09-01 23:37:42 +02:00
Oliver Kieselbach fb49dc9ad2 fix(backend): bind device block/kill to the certificate identity and repair session-scoped auto-unblock
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>
2026-09-01 23:37:41 +02:00
Oliver Kieselbach 27bf96f23f docs: demo session import concept (OKF)
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>
2026-09-01 23:15:27 +02:00
Oliver Kieselbach 0f1f2e593f chore: bump .claude/commands (import-session skill)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:14:51 +02:00
Oliver Kieselbach 4b5ae562c7 fix(web): drop retired /docs, /roadmap and /changelog from sitemap lastmod generator
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>
2026-09-01 22:55:54 +02:00
Oliver Kieselbach d6783fe327 feat: free-text note search on the annotation lists
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>
2026-09-01 22:49:35 +02:00
Oliver Kieselbach 2fe1095758 perf(raw-events): budgeted, resumable cross-session scan; tenant from index rows; MCP pageSize override + timeout retry
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>
2026-09-01 22:35:46 +02:00
Oliver Kieselbach 8a3b4136a6 feat(web): internal annotation lane follows the Global-Admin view
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>
2026-09-01 20:43:58 +02:00
Oliver Kieselbach 7323ca72b2 feat(web): demo presentation mode for live product demos (#141)
* 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>
2026-09-01 20:30:54 +02:00
Oliver Kieselbach 52ae6541a7 feat(agent): signal-pipeline health block in agent_metrics_snapshot
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>
2026-09-01 18:26:22 +02:00
Oliver Kieselbach d763a885e2 Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140)
* 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>
2026-09-01 16:57:41 +02:00
dependabot[bot] eedca11214 build(deps): bump the web group (#139)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.11.3` | `12.11.5` |
| [next](https://github.com/vercel/next.js) | `16.3.2` | `16.3.3` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.2` | `16.3.3` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.4` | `19.2.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.2` | `16.3.3` |


Updates `@xyflow/react` from 12.11.3 to 12.11.5
- [Release notes](https://github.com/xyflow/xyflow/releases)
- [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md)
- [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.11.5/packages/react)

Updates `next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.2...v16.3.3)

Updates `@next/bundle-analyzer` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/next-bundle-analyzer)

Updates `@types/react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `eslint-config-next` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@xyflow/react"
  dependency-version: 12.11.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 15:53:05 +02:00
dependabot[bot] 73a6817713 build(deps): bump github/codeql-action from 4.37.3 to 4.37.9 (#138)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.3 to 4.37.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.3...v4.37.9)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 15:52:26 +02:00
Oliver Kieselbach 0a0c323f73 fix(backend): CMTrace skew tripwire judges only the current ingest era
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>
2026-09-01 13:53:59 +02:00
Oliver Kieselbach bac26def52 commands: bump to ad441d6 (ux-insights gegen die reale Telemetrie verifiziert)
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>
2026-08-31 19:17:53 +02:00
Oliver Kieselbach 4c70ea8a0a feat(web): /buy — Marketplace card first, direct-purchase channel named Cleverbridge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:15:28 +02:00
Oliver Kieselbach eaef1601d0 feat(backend,mcp,web): typisierung follow-up — the deliberately-untyped remainder is typed end-to-end, and the raw table entities leave the wire (#137)
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>
2026-08-31 15:22:16 +02:00
Oliver Kieselbach 51737174b2 feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:32:51 +02:00
Oliver Kieselbach c04280cf68 feat(mcp): MCP server adopts the generated wire types — the codegen now writes a second copy of wire-types.generated.ts into McpServer/src/generated (MCP_HEADER, buildMcpWireTypesSource, same freshness contract), pinned by a new MCP vitest freshness test (repo-root discovery like event-types-drift, byte-equal to a fresh codegen run, mutation-proven red/green) and enforced in the CI shared-manifests-in-sync diff check; the hand-declared response shapes and structural inline casts in tools/*.ts now import the generated types on the INPUT side only (tool output contracts unchanged): sessions.ts (GetSessionResponse/GetSessionEventsResponse/GetRuleResultsResponse/GetSessionAnnotationsResponse/DiagnosticsDownloadTicketResponse; get_session_summary events are Partial<EnrollmentEvent>, RuleResult drops the dead ruleTitle??title fallback), admin.ts (usage union GetMcpUserUsage/GetGlobalMcpUsage/GetGlobalMcpUsageDaily, GeographicLocationSessionsLeanResponse, GetAllTenantConfigurationsResponse, QueryRawTableResponse, GetUnmatchedSoftwareResponse, SoftwareInventoryResponse; the client-side pagers pin their wire-critical records/summaries/inventory key names via satisfies keyof so a backend rename goes tsc-red instead of silently paging nothing), search.ts (GetSessionEventsResponse on both event readers, SearchSessionsResponse on the legacy fallback, RawEventsPage = Partial<Pick<QueryRawEventsResponse,...>>), rules.ts (DryRunAnalyzeRuleResponse envelope; the inner RuleDryRun trace stays a local reader because the wire type is deliberately unknown); the raw-session fallback in get_session_diagnostics/get_session_summary is removed — { success, session } IS the pinned contract, and the diag test now asserts a non-enveloped object surfaces as a loud tool error instead of being silently absorbed; rule-stats, global/metrics/platform and verdict-calibration keep local readers (backend still serializes anonymous objects there — no DTO exists to import); MCP suite 617 green, tsc green, generator idempotent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 03:19:47 +02:00
Oliver Kieselbach d055eed074 Typed API contract: every success response is a wire DTO, generated end-to-end into TypeScript (#136)
* 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>
2026-08-31 02:30:39 +02:00
Oliver Kieselbach 2b6565b3cb fix(agent): IME matcher line budget charges thread CPU cycles, regex timeouts get one starvation-gated retry - the wall-clock budget and the 1s .NET regex timeout both measured guest-scheduler starvation as match cost on CPU-saturated Hyper-V VMs (session b9f1d134: PS-AGENT-OUTPUT timed out on a line it genuinely matches in 5.8us, factor ~170,000, with 2.5s of process-wide log silence and CPU pinned at 100% during the DeviceSetup script burst; six spurious budget breaks the same pass); MatchLine now charges the per-line budget in cycles the poll thread actually executed (QueryThreadCycleTime, probed once, Stopwatch wall-tick fallback preserving the old behaviour exactly), and a RegexMatchTimeoutException whose attempt consumed < 0.5s of match cost (starvation, not spinning) is retried exactly once - counted into the new regexTimeoutRetries (recovered, NOT skipped work, no degraded event) while an attempt at/over the gate is skipped without a second helping of CPU, so regexTimeouts now means "really burned CPU"; the gate is unreachable in the wall-clock fallback by construction (no retry there); retries surfaced through health snapshot, restart-safe persistence, ime_tracker_degraded, ime_pattern_hits and agent_metrics_snapshot (ime_regex_timeout_retries); known limit: hypervisor-level vCPU steal stays invisible to the guest kernel and still charges cycles - strict improvement over wall clock, not perfect steal accounting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:49:15 +02:00
Oliver Kieselbach 5f3ae2d06b fix(backend): SessionTenantLookup is claimed first-writer-wins at registration — the sessionId→tenantId row that every Global Admin/Reader cross-tenant session resolve trusts was an unconditional last-writer-wins upsert AFTER the session write keyed by the client-supplied sessionId, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-tenantId GA read and GA write (globaladmin annotation lane, restore fallback) silently served from a forged session; StoreSessionAsync now claims the row create-only BEFORE any Sessions/SessionsIndex write (409 → read owner: same tenant = re-registration, foreign tenant = SessionTenantConflictException → 409 session_owner_mismatch without disclosing the owner + SessionTenantConflict ops event, nothing written, not fail-soft), the legacy self-heal write is Add-only, ResolveSessionScopeAsync(requireGlobalAdmin) corroborates the resolved tenant against its Sessions row (silent, no operator confirmation), and scripts/Migration/Backfill-SessionTenantLookup.ps1 closed the pre-table window (13509 sessions, 8795 rows added, verify dry-run added=0); no tombstone on deletion by design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:34:50 +02:00
Oliver Kieselbach 83e15e49de fix(core,backend): every payload-derived string stored into DecisionState is bounded (FactStringBounds, 256) and the reducer-verification replay streams signals — RealmJoinFacts.WithPackageStarted/Completed left PackageId/Version/Scope unbounded and deduped via a linear ordinal compare over up to 200 rows, so a forged 5000-signal stream with ~400 KB near-identical package ids cost minutes of CPU per GET sessions/{id}/reducer-verification while ReducerVerifier held all decoded payloads (~GBs) live; keys are now bounded BEFORE the dedupe scan (forged near-duplicates collapse to one row), Scope normalised to machine/user, and the same bound applied to failed app ids, HelloOutcome, ImeMatchedPatternId, EspAdvisoryFailureCategory and RJ product version/channel incl. WithVersionOverride — never truncating real telemetry (registry key names ≤ 255, GUIDs, enum-like strings); the agent adapter bounds packageId/version at the source; ReducerVerifier replays decode-fold-discard (head-signal check before the journal so stubbed streams stay silent, mid-stream truncation and count-imbalance semantics unchanged, pinned by tests); ISignalRepository.QueryBySessionAsync carries a cumulative PayloadJson budget (SignalQueryLimits, 32 M chars) reflected in the truncated flag of sessions/{id}/signals and reducer-verification; no ingest cap — stored signals stay verbatim
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:06:51 +02:00
Oliver Kieselbach 3c8ef02191 feat(agent): RealmJoin self-update is observed and reported as realmjoin_autoupdate_detected — realmjoin_detected read the version once from RealmJoin.exe, but the tray AppUpdater often replaces the MSI-shipped binary on its first start minutes after detection (gktatooine 946ccbd6: 4.21.4 -> 4.21.18, whole deployment ran on the new build while the timeline and RealmJoinFacts kept 4.21.4); the updater's only registry write is HKLM\...\Uninstall\RealmJoin (DisplayVersion/InstallDate), so RealmJoinWatcher arms a RegNotifyChangeKeyValue watcher there once Detected fired (appearance-watch on the Uninstall parent until the tray creates the entry), re-reads the binary version after a 2 s debounce (DisplayVersion fallback while the file is locked, channel from baseline) and emits the Info event {previousVersion,newVersion,releaseChannel} plus the typed RealmJoinAutoUpdateDetected signal, whose reducer overrides RealmJoinFacts.ProductVersion/ReleaseChannel (WithVersionOverride) and touches neither gate, deadline nor LastActivityUtc; unreadable baseline at detection is seeded silently; portal device card prefers the updated version ("auto-updated from X"); event registered in Constants/resource-catalog/shared-manifests with replay fixture and watcher/adapter/reducer tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:02:39 +02:00
Oliver Kieselbach 251e82a45a fix(mcp): tool-call logging never records tenant-config secret values — withToolTelemetry/summarizeArgs gain a per-argument ArgPolicy ('keys' | 'drop') declared at the call site, update_tenant_config logs only the NAMES of args.fields (webhook/SAS URLs and custom headers previously went verbatim, up to 200 chars, into the container log stream under the IaC-default MCP_TOOL_LOGGING=true, readable by log consumers below Global Admin); values remain in the GA-gated backup snapshot and audit log. Backend: TenantConfigPatchService no longer returns the Newtonsoft ex.Message on a JSON type mismatch (it quotes the offending value, so a secret sent into a numeric field echoed into the tool response and log line) — only the field path is named. Tests for keys-only/drop rendering, log-line content on success and soft-error paths, and the backend error text
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:58:23 +02:00
Oliver Kieselbach 01311898a8 fix(ci): no third-party action holds release/deploy credentials by mutable tag — build-agent creates releases with first-party gh release create (job holds contents/id-token/attestations:write), combine-rules pushes generated outputs with plain git (contents:write on main), deploy-web builds the static export in CI (setup-node 22, npm ci, next build — same path ci.yml tests) and uploads it with the exactly pinned SWA CLI 2.0.10 instead of Azure/static-web-apps-deploy (Oryx in-container build, deploy token as action input), attests portal-<sha>.tar.gz and drops the unusable per-PR preview environments; azure/login and Azure/functions-action are SHA-pinned with version comments for Dependabot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:21:19 +02:00
Oliver Kieselbach a5bca8a730 fix(web): CSV exports neutralize spreadsheet formula triggers in device-controlled cells (CWE-1236) — generateCsvExport/generateSessionCsvExport/generateRuleResultsCsvExport only doubled quotes, so an event Message, EventType, Source, DataJson, DeviceName, SerialNumber, FailureReason or rule explanation starting with =, +, -, @, tab or CR was evaluated as a formula (HYPERLINK/WEBSERVICE exfiltration, DDE) when a tenant admin opened the exported CSV or the operator opened the problem-report attachment; shared csvCell() now prefixes such cells with an apostrophe in addition to quoting, with regression tests for the DDE/HYPERLINK payloads and all three exporters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:54:40 +02:00
Oliver Kieselbach 87cb1f1cea fix(backend): mcp-usage/user/{oid} filters usage rows to the caller's tenant and never signals foreign existence — UsageCrossTenantGuard skipped rows with an empty TenantId (legacy rows without the column, tokens without a resolvable tid), so a self-service tenant admin of any tenant could read another tenant's user usage (UPN, endpoints/tool names, per-day counts) by oid; and the distinct 403 CrossTenantAccessDenied vs 200-empty made the route a cross-tenant user-existence oracle; FilterForCaller now returns only rows whose TenantId equals the caller's tid for non-global callers (empty TenantId or empty caller tid ⇒ nothing), the handler always answers 200 with the filtered set (foreign oid ≡ unknown oid), dropped rows are logged as an ops warning only; tests rewritten for the filter semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:38:16 +02:00
Oliver Kieselbach 44dd8161df fix(agent): ImeProcessWatcher verifies process identity before attaching and re-arms discovery after an exit — it attached to the first IntuneManagementExtension name match, so a standard user starting a renamed binary during AccountSetup could forge an ime_process_exited Warning and mute the real signal for the rest of the session (CWE-807), and even a legitimate IME service restart was never watched again; ImeProcessIdentity requires session 0 + image under the IME install root (QueryFullProcessImageName, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and OnImeExited restarts the discovery timer (ReferenceEquals guard against duplicate events)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:07:18 +02:00
Oliver Kieselbach c84a1c23eb fix(backend): registration StartedAt is clamped to the event skew window (+24h / -168h around server time) in the shared register core — the device-supplied anchor drives the SessionsIndex RowKey, the retention filter, TotalToday and the supersede-predecessor comparison, so a far-future value pinned the row at the head of every list (incl. the Global Admin cross-tenant list), inflated today's counters forever and never became a retention candidate; both cert-auth and bootstrap entries flow through ProcessRegisterAsync, re-registration keeps the earlier stored anchor (min-merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:03:05 +02:00
Oliver Kieselbach 4a7eb2749d fix(backend): bootstrap revoke enforces code ownership — DELETE /api/bootstrap/sessions/{code} only scoped the caller's ?tenantId while the repository resolved the owning tenant from CodeLookup and revoked any tenant's code; RevokeBootstrapSessionAsync now takes the caller tenant and refuses a mismatch as not-found (no owner leak, foreign partition never read), with regression tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:59:32 +02:00
Oliver Kieselbach a536553ee3 fix(web): IME Tracker card shows skip counters without the ime_tracker_degraded pointer — a tenant admin cannot act on it; a tooltip explains that pattern coverage may be incomplete and points to Report Session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:38:07 +02:00
Oliver Kieselbach ce202c6d47 fix(ops): SlaBreachNotification ops event prints Duration breaches in minutes — the P95 value carried the % unit of the rate breach types ("Duration 864.0% (target 60.0%)"); the unit now follows SlaBreachType like the customer-facing alert builder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:19:59 +02:00
Oliver Kieselbach 74902bf185 fix(agent): IME per-line matching budget counts Regex.Match time only — the wall-clock window also held the match handlers and scheduler stalls, and session 946ccbd6 (Hyper-V guest, 5–10 s process-wide stalls, 1 MB log, no line over 5 KB) broke it twice on genuine lines and silently skipped their remaining patterns; ime_pattern_hits is Debug severity (operator/backend signal, hidden from the default timeline — the IME Tracker card and the ingest fold read it regardless); the session page's IME Tracker card moves to the right column under Hardware
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:00:28 +02:00
Oliver Kieselbach 4f021852ee updated command 2026-08-30 15:14:26 +02:00
Oliver Kieselbach 7a3516e570 feat(observability): IME pattern-drift loop — nothing the log tracker skips or stops matching stays silent: the agent keeps cumulative tracker health counters and a per-pattern match histogram (restart-safe in ImeTrackerState), reports ime_* keys in agent_metrics_snapshot (files tailed, backlog bytes, lines read, matches, oversized/timeouts/budget breaks/held tails/unanchored), raises a one-shot immediate-upload ime_tracker_degraded Warning on the first pass that skipped work, and emits ime_pattern_hits at termination (every enabled pattern, zeros included, IME version); the backend folds the histogram into the permanent per-IME-version ImePatternStats table (built-in IDs only, plausible version only, one partition read + one transactional batch) and evaluates drift without a hard-coded list — baseline = the version with the most reporting sessions (≥100), expected = ≥80 % hit rate there, drift = an expected pattern matched in none of ≥25 sessions on a newer version — raising ImePatternDriftSuspected once per version×pattern (ETag-stamped, dual-registered in the web ops-event list with a new guard test); GET metrics/ime-pattern-health (GlobalReadOrAdmin) + Global Admin → Metrics → IME Pattern Health matrix (drift/silent/low/few cells, open alerts, retired patterns) + session-page "IME Tracker" rows (latest snapshot by max sequence) + MCP get_ime_pattern_health (GA/Reader)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:12:38 +02:00
Oliver Kieselbach 6907a41a84 updated command 2026-08-30 14:40:27 +02:00
Oliver Kieselbach f4a5eac539 chore(rules): IME pattern-pack hygiene against the IME decompiles (1.97.107.0 / 1.104.102.0 / 1.105.103.0) — no wording drift in any shipped pattern; the 11 disabled legacy patterns whose literals last existed in IME 1.50 (fleet minimum 1.97) are deleted instead of left as re-enable traps (IME-DETECTED-OLD, IME-SET-CURRENT-1/2/3, IME-SKIP-APPLICABILITY-NOTMET, IME-SKIP-DEPENDENCY-DETECT, IME-SKIP-DETECTED, IME-SKIP-UNINSTALL-NOTDETECTED-2, IME-UPDATE-NAME-1/2/3); IME-DO-TIMEOUT-1/2 match the exact source literal "DO downloading is not finished within timeout, jobId = …, fileId = …" instead of two lookaheads; IME-DO-TEL documented as emitter-less from 1.104 on and kept only for the 1.101/1.103 fleet share
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:23:14 +02:00
Oliver Kieselbach 09492e908c fix(security): IME log-pattern pack is anchored and linear (all 90 patterns start with ^, IME-APP-VERSION spells the report-delta JSON out instead of a chained [^}]*? scan) and the ImeLogTracker read loop is bounded — BoundedLineReader (32 MB per line, evidence: real 5.6 MB "Get policies" lines; exact byte bookmarks, no decoder read-ahead loss on cancel), 2 s per-line matching budget across all patterns, held-back unterminated EOF tails (≤1 MB, 1 s settle) instead of dropping them and raw-matching fragments, a complete entry after a dropped one is no longer swallowed as its close, one Warning per pass (oversizedLines/regexTimeouts/lineBudgetBreaks) instead of silent Debug timeouts; unanchored PS-SCRIPT-CONTEXT false positives on [Win32App] SideCarScript… lines (which overwrote the pending platform script's RunContext) are gone; --run-ime-matching assembles multiline entries like the tracker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:03:50 +02:00
Oliver Kieselbach f432353efa fix(security): startup self-update no longer trusts the on-disk remote-config.json cache — Program.LoadCachedSelfUpdateContext (raw deserialize, bypassed the read-side strip) is removed, the boot-time check runs with allowDowngrade:false and the version-v2.json manifest hash as its only integrity gate; LatestAgentSha256 (update-ZIP hash, ranked above the manifest hash in SelfUpdater) joins the CacheConfig/LoadCachedConfig strip lists so neither the startup nor the FromCache runtime path can take a planted ZIP hash or downgrade flag; verify+extract already share one exclusive handle (1daf631a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 13:53:00 +02:00
Oliver Kieselbach 1daf631a02 fix(security): self-update verifies and extracts the ZIP through one exclusive FileShare.None handle — SHA-256 is computed on the same stream the ZipArchive reads, so the file can no longer be swapped between verification and extraction (TOCTOU); handle is released before cleanup, open failure aborts with integrity_mismatch marker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 13:03:50 +02:00
Oliver Kieselbach d0190f443d fix(ci): build-agent workflow_dispatch build_number can no longer inject PowerShell — the input and the agent blob SAS reach the version step only via env (BUILD_NUMBER_OVERRIDE, AGENT_SAS_TOKEN) instead of ${{ }} in the script body, and the override is validated against ^\d+$ before the [int] cast and the counter-blob CAS; counter semantics unchanged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:37:36 +02:00
Oliver Kieselbach e96f49799f fix(security): McpUsers whitelist rows are honoured only for the identity the UPN is bound to (AdminIdentityBindings tid+oid) — grant, UsagePlan override and Disabled kill-switch no longer follow a same-UPN token from another tenant or a recycled account; POST global/mcp-users binds first (resolver, 422 HomeTenantUnresolved, 409 conflict) and the settings UI shows the home-tenant picker on 422; unbound legacy rows are inert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:13:03 +02:00
Oliver Kieselbach 4864b2d21d fix(security): gather RuleStats resolve the device-claimed ruleId against the tenant's active gather catalog — title/category/severity come from the catalog rule, unknown or inactive IDs are dropped, and the cross-tenant global_* row is written only for IsBuiltIn/IsCommunity catalog rules (same design as the analyze path); a forged GatherRuleExecutor event can no longer inflate fleet-wide counters, respell a built-in rule's title or mint global rows for retired IDs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:13:00 +02:00
Oliver Kieselbach 56c37866d1 fix(security): ImeVersionHistory no longer takes a device-reported IME version as a platform fact — RecordImeVersionAsync rejects strings outside MSI ProductVersion bounds (no row/ops event/archive job), a session counts once (UpdateSessionImeAgentVersionAsync returns whether the version changed), a second tenant's sighting stamps CorroboratedAt, and the MemberRead view lists only fleet-confirmed versions (Archived, corroborated, or pre-2026-08-31) while Global scope still sees every row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:38:27 +02:00
Oliver Kieselbach 2852418413 fix(web): progress portal Cloud PC hint becomes a native input tooltip
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>
2026-08-30 11:37:52 +02:00
Oliver Kieselbach 268d8652f1 fix(web): progress portal serial input no longer hides its Cloud PC hint
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>
2026-08-30 11:29:58 +02:00
Oliver Kieselbach 66f5543fb7 fix(security): ConsoleBypassWatcher mirrors cmd.exe first-switch-wins parsing — only a leading /c classifies as scripted; a /c inside a /k payload (cmd /k rem /c, cmd /k "title /c") no longer silences a persistent interactive console, which is surfaced as interactive_console_with_command (low)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 02:05:55 +02:00
Oliver Kieselbach 21d200a1e4 fix(security): config PUT derives its preserved system-owned fields from the PATCH deny-list (BaseDeniedFields) so ProDowngradedUtc/OnboardedAt can no longer be written by a tenant admin — a client-supplied ProDowngradedUtc refreshed the retention-downgrade grace anchor indefinitely, keeping the Pro retention cap on a Community tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 02:01:32 +02:00
Oliver Kieselbach 8c0cb2c423 fix(security): RemoveGlobalAdmin, RemoveTenantAdmin, DisableTenantAdmin and sub-Admin UpdateMemberPermissions now call DisconnectUserAsync(upn) so a revoked/demoted user loses already-joined global-admins / tenant-{tid} / notify / session SignalR streams instead of keeping them until the socket drops (group authz is join-time only)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:59:51 +02:00
Oliver Kieselbach 84d06e1cf6 fix(security): LocalAdminAnalyzer never exempts a logged-in local SAM account from the shutdown scan (CWE-807) and matches built-ins by well-known RID so localized Guest/Administrator are not flagged
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>
2026-08-30 01:42:51 +02:00
Oliver Kieselbach 88dfd331dd fix(security): LocalAdminAnalyzer inventories disabled accounts and reads Administrators-group membership from the SAM (NetLocalGroupGetMembers on S-1-5-32-544); a dormant /active:no admin backdoor now surfaces as unexpected_admin_members (+40, Error), built-in Administrator enabled-state reported for evidence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 01:00:29 +02:00
Oliver Kieselbach d294415d29 fix(security): CmTraceLogParser parses in linear time (first open / last parseable trailer, timeout-backed trailer regex) instead of a greedy .* regex that backtracked quadratically on hostile lines without a timeout; agent multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:48:29 +02:00
Oliver Kieselbach 76af1234ed fix(security): BlockedDevices rows are keyed by the canonicalized serial (trim + upper-invariant) so case-variant serials from a rogue agent or an admin's inventory copy still hit the block/kill row; legacy verbatim-case rows are dual-read and lazily re-keyed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:27:07 +02:00
Oliver Kieselbach 0515523e49 fix(security): guardrails.json values are emitted into generated TS through a complete encoder (JSON.stringify + U+2028/29) and rejected up-front if they contain control or line-terminator characters; new guardrails schema validated in combine-rules before any bot commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 00:19:52 +02:00
Oliver Kieselbach a47e94f9a5 fix(security): diagnostics packager reads every file through a handle validated against its pinned source folder; a subdirectory swapped for a junction between enumeration and open is skipped instead of copied
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>
2026-08-30 00:06:50 +02:00
Oliver Kieselbach ecfa59d049 fix(security): PUT preview/notification-email requires a member role once the tenant has members; roleless writes only in the signup window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:57:35 +02:00
Oliver Kieselbach 6c50a54249 fix(security): escape tenantId in metrics-summary and device-snapshot OData filters; GUID-gate tenantId query param on global metrics/search routes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:55:15 +02:00
Oliver Kieselbach 6e3487756a fix(security): gather-rule on_event feedback loop — timeline feed carries the event source and never dispatches on_event for gather-emitted events; per-rule on_event cap (100/session); backend rejects rules triggering on their own output type / gather_result
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:54:44 +02:00
Oliver Kieselbach dbb154bae9 fix(security): percent-encode every path-position id in lib/api.ts; session pages gate URL-derived id on GUID before any fetch
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>
2026-08-29 23:45:34 +02:00
Oliver Kieselbach c41c2327ab fix(security): validate enrollment summary branding image URL (https-only, no quotes); launcher drops unsafe URLs instead of interpolating them into the dialog command line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:15:07 +02:00
Oliver Kieselbach 83effea592 fix(security): hosted diagnostics upload SAS is create-only and bound to the requesting session; existing blob => 409
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:13:00 +02:00
Oliver Kieselbach 1a4a37b77a fix(web): hardware whitelist editor splits typed comma lists into entries ("Dell*, *" stays allow-all); only insights buttons neutralize ','
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:56:37 +02:00
Oliver Kieselbach f56851fc9f fix(security): hardware whitelist — one added entry is always one pattern; server rejects malformed CSV
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>
2026-08-29 22:51:55 +02:00
Oliver Kieselbach cbe410cbbc fix(security): reject path-shaped sessionId/tenantId in session-report blob names; BlobNameGuard at every GetBlobClient sink
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>
2026-08-29 22:51:13 +02:00
Oliver Kieselbach 43ddc60111 fix(security): enforce tenant suspension in PolicyEnforcementMiddleware, not only auth/me
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>
2026-08-29 22:34:29 +02:00
Oliver Kieselbach 82b26b4ab9 fix(security): HTML-encode domain in transactional mails; freeze DomainName on config PUT, validate first-login seed
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>
2026-08-29 22:33:52 +02:00
Oliver Kieselbach 1733f4fd48 fix(agent): SignalIngress worker re-entrancy — never block the single consumer on its own post
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>
2026-08-29 22:11:25 +02:00
Oliver Kieselbach fc46b4c78e feat(security): session-owner binding stage 1 (shadow) + dormant agent session rotation
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>
2026-08-29 21:32:24 +02:00
Oliver Kieselbach c18de5d76c fix(security): drop client-writable certificate header; bootstrap routes token-only, fail-closed
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>
2026-08-29 20:04:04 +02:00
Oliver Kieselbach 250cc667dd fix(web): show full UPN in admin feedback list instead of a 200px truncation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:58:08 +02:00
Oliver Kieselbach d3261fb890 feat(ops): ops events for Collect Logs usage and diagnostics-upload enablement
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>
2026-08-29 19:30:20 +02:00
Oliver Kieselbach b3bbcf1300 fix(agent): rotate_config applies the fetched config; diagnostics skip gates report their reason
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>
2026-08-29 19:30:00 +02:00
Oliver Kieselbach a306cd9e40 feat(security): resolve admin identity bindings automatically at grant time; per-tenant bindings panel
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>
2026-08-29 19:21:47 +02:00
1826 changed files with 162389 additions and 42544 deletions
+29 -23
View File
@@ -1,37 +1,43 @@
# Agent Directives
- **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.
- 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
-136
View File
@@ -1,136 +0,0 @@
# Autopilot-Monitor — Agent Architecture Principles
The principles are **not nice-to-haves**, they are review criteria: any code
change that violates a principle is either (a) a bug, (b) a reason to change the principle
(with user sign-off), or (c) an explicitly documented exception with rationale.
---
## L.1 Truth Hierarchy (immutable)
`SignalLog` (input) → `Journal` (decisions) → `Snapshot` (cache) → `Projections` (derivable).
No projection patches backwards into the journal or the signal log. On inconsistency, the
lower layer always wins (e.g. discard the snapshot, do not "repair" the signal log).
## L.2 Event Sourcing as Foundation
`SignalLog` is the only input truth. The reducer is pure:
`(oldState, signal) → (newState, transition, effects)`. The backend can re-derive at any
time from `SignalLog`. Replay yields the same `Journal` content.
## L.3 Immutable State
All DTOs in the decision core are `sealed` with get-only properties. No in-place mutation.
"Change" = new instance via `With…` method. This is the difference between "purely testable"
and "subtle heisenbugs".
## L.4 Hypothesis Model instead of Flow Plugins
Fuzziness is constitutive (White-Glove, enrollment type, device-only). A single reducer with
hypothesis fields (`Unknown/Weak/Strong/Confirmed/Rejected`), organized by scenario in partial
classes. **No** plugin runtime, because clean flow boundaries do not match reality.
## L.5 Evidence Determinism
Signal-log determinism is guaranteed: same log → same outcome. Every signal carries an
`Evidence` record with `Kind` (Raw / Derived / Synthetic) and mandatory fields. A signal
without evidence = adapter rejection = bug.
## L.6 Signal Schema Versioning
Every `DecisionSignalKind` has a `SchemaVersion`. The reducer dispatches on
`(Kind, SchemaVersion)` — old versions stay replay-capable. A new version or a new kind
without a replay fixture in `tests/fixtures/signal-kinds/{kind}-v{n}.json` = merge block.
## L.7 Timer Monopoly
Only the Deadline Scheduler (the EffectRunner role) holds decision-relevant timers. Collectors,
signal adapters and the orchestrator must not. `Task.Delay` or `new Timer(...)` in those
layers with session-state impact = merge block. `IClock` interface + virtual clock for
testability.
## L.8 Classifier as Post-Hook Kernel Service
`IClassifier` interface, swappable. The `ClassifierVerdict` flows back into the log as a
synthetic `ClassifierVerdictIssued` signal. `InputHash` (SHA256 over classifier-relevant
snapshot fields) is the anti-loop guard. Classifiers are not baked into the state machine.
## L.9 Clean-Agent Principle on Refactors
For structural refactors: build the new project in parallel to the legacy one, do not flag-
route inside the same process. Strict separation rule: no direct reference to legacy
projects. Code is copied, not shared. Legacy runs out, then gets removed.
## L.10 Generic Telemetry Transport
One spool for all telemetry types (events, signals, decision transitions). `TelemetryItemId`
is the type-agnostic upload cursor; the type-specific counters (`Event.Sequence`,
`SignalOrdinal`, `StepIndex`) remain storage / UI / reducer semantics. Drain guarantee:
"coordinated + idempotent + resumable" — **not** cross-table atomic, which is not achievable
with three separate tables anyway.
## L.11 Separation: Agent Lifecycle vs Session State
`DecisionState` is session-pure. Agent-process flags (crash, admin actions, boot time,
heartbeat status) live separately in `agent-lifecycle.json`, managed by the orchestrator,
never by the reducer. Prevents replay dilution by lifecycle events.
## L.12 Flush-on-Append Persistence
No batching of append operations. `FileOptions.WriteThrough` plus an explicit
`FlushAsync(flushToDisk: true)` per append. Every successful `Append` return means: on disk.
Durability > performance in the crash-prone OOBE context.
## L.13 UI Primacy — End-Users vs Dev Tooling
Product views (EventTimeline, PhaseTimeline) stay slim and stable. Debug / analysis tools
(Inspector with DecisionGraph, SignalStream, VerifierReport) live on their own subpage behind
an `adminMode` gate. The session-detail page does not get overloaded with dev components.
## L.14 Forward Compatibility by Default
Enums via `StringEnumConverter` + `UnknownFallbackEnumConverter` (unknown values → default,
never crash). New schema fields are nullable. The `ReducerVersion` stamp per journal row
allows older backends to read newer rows. Deletions only after a major version bump with
user sign-off.
## L.15 Test Discipline
Flow / scenario tests are prioritized over structural tests. Coverage targets per component
(DecisionCore ≥95 %, classifier 100 %, backend functions ≥85 %). A missing fixture for a
scenario = merge block. CI threshold check is a build failure, not a warning.
## L.16 Fail-Safe Reducer Exceptions
Exception in the reducer → `DecisionTransition` with `DeadEndReason=reducer_exception`, state
unchanged (fail-safe, never destroy state). Signal counts as processed (no retry loop). After
five consecutive exceptions → the orchestrator emits
`enrollment_failed(reason=reducer_instability)`.
## L.17 Effect Error Handling by Class
Three effect classes with their own strategy:
- **Transient** (retry with exponential backoff 100 ms / 400 ms / 1600 ms):
`EmitEventTimelineEntry`, `PersistSnapshot`
- **Critical** (session abort, e.g. on timer-register failure): `ScheduleDeadline`,
`CancelDeadline`
- **Optional** (best-effort, classifier exception → `Inconclusive` verdict, no abort):
`RunClassifier`
Every effect failure is logged as a `DecisionTransition`.
## L.18 Implementation Discipline
- **R1** Pre-flight plan check before every work step
- **R2** Delegate self-contained sub-tasks **and exploration** to subagents (Anthropic
recommendation: preserve main-context window on large refactors — quality assurance)
- **R3** Real verification (DB round-trip, index dual-write, agent signal emission) — no
"it compiles and runs"
- **R4** The plan is authority — deviations require user sign-off
- **R5** Out-of-scope items land in `tasks/todo.md`, not in memory
- **R6** High test coverage is a prerequisite for future refactorability, not a nice-to-have
- **R7** Code-structure discipline: small files (guideline ≤300 LoC), clear folders, partial
classes by scenario, check in with the user on any uncertainty (placement, naming, scope)
---
+49
View File
@@ -0,0 +1,49 @@
name: Bug report
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.
render: text
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Question or idea
url: https://github.com/okieselbach/AutopilotMonitor/discussions
about: Ask how something works or float a feature idea before it becomes a request.
- name: Documentation
url: https://docs.autopilotmonitor.com
about: Setup guides, troubleshooting, and the security FAQ.
- name: Security vulnerability
url: https://github.com/okieselbach/AutopilotMonitor/security/advisories/new
about: Report privately. Please never open a public issue for a security finding.
@@ -0,0 +1,34 @@
name: Feature request
description: Propose something Autopilot Monitor should do.
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem are you trying to solve?
description: Describe the situation, not the solution. What are you doing today instead?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: What would you like to see? Rough is fine.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- Web portal
- Agent (on device)
- Analysis rules / diagnostics
- MCP server / AI integration
- Notifications / integrations
- Reporting
- Other
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html><head><meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
html,body{margin:0;padding:0;}
body{width:1280px;height:640px;overflow:hidden;font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;color:#0f172a;
background:
radial-gradient(900px 600px at 105% 110%, #dcfce7 0%, rgba(220,252,231,0) 60%),
radial-gradient(700px 500px at -10% -10%, #e0f2fe 0%, rgba(224,242,254,0) 60%),
linear-gradient(180deg,#ffffff 0%,#f6f8f7 100%);
position:relative;}
.grid{position:absolute;inset:0;background-image:linear-gradient(rgba(15,23,42,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(15,23,42,.045) 1px,transparent 1px);background-size:40px 40px;mask-image:linear-gradient(180deg,rgba(0,0,0,.7),rgba(0,0,0,0) 85%);}
.left{position:absolute;left:72px;top:0;height:640px;width:520px;display:flex;flex-direction:column;justify-content:center;z-index:2;}
.brand{display:flex;align-items:center;gap:14px;margin-bottom:26px;}
.brand svg{width:44px;height:40px;}
.brand span{font-size:22px;font-weight:600;letter-spacing:-.01em;color:#0f172a;}
h1{margin:0 0 18px;font-size:50px;line-height:1.06;font-weight:800;letter-spacing:-.035em;}
h1 em{font-style:normal;color:#16a34a;}
p.tag{margin:0 0 30px;font-size:20px;line-height:1.45;color:#475569;font-weight:400;max-width:480px;}
.chips{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:34px;}
.chip{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:#fff;border:1px solid #e2e8f0;box-shadow:0 1px 2px rgba(15,23,42,.05);font-size:15px;font-weight:500;color:#1e293b;}
.chip i{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;}
.chip.b i{background:#3b82f6}.chip.p i{background:#8b5cf6}
.foot{display:flex;align-items:center;gap:14px;font-size:15px;color:#64748b;font-weight:500;}
.foot b{color:#0f172a;font-weight:600;}
.foot .dot{width:4px;height:4px;border-radius:50%;background:#cbd5e1;}
.shot{position:absolute;left:600px;top:76px;width:1000px;height:640px;border-radius:14px;background:#fff;
box-shadow:0 30px 80px -20px rgba(15,23,42,.35),0 0 0 1px rgba(15,23,42,.08);overflow:hidden;z-index:1;}
.bar{position:relative;z-index:2;height:34px;background:#f8fafc;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;padding:0 14px;gap:7px;}
.bar i{width:10px;height:10px;border-radius:50%;background:#e2e8f0;display:inline-block;}
.bar .url{margin-left:12px;flex:0 0 auto;height:20px;padding:0 10px;border-radius:6px;background:#fff;border:1px solid #e2e8f0;font-size:11px;line-height:20px;color:#64748b;}
.shot img{position:absolute;left:-214px;top:-16px;width:1440px;height:900px;transform-origin:top left;transform:scale(.94);}
</style></head>
<body>
<div class="grid"></div>
<div class="left">
<div class="brand">
<svg viewBox="0 0 209 191" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 180.201L208.401 190.502L188.157 76.2438L5.48363e-06 0L0 180.201Z" fill="#33B161"/></svg>
<span>Autopilot Monitor</span>
</div>
<h1>See every Windows<br>enrollment <em>as it happens</em></h1>
<p class="tag">Live session timelines, fleet health, and on-demand diagnostics for Windows Autopilot and Intune.</p>
<div class="chips">
<span class="chip"><i></i>Live session timeline</span>
<span class="chip b"><i></i>Fleet health</span>
<span class="chip p"><i></i>MCP server for AI troubleshooting</span>
</div>
<div class="foot"><b>autopilotmonitor.com</b><span class="dot"></span>Open source<span class="dot"></span>Free Community plan</div>
</div>
<div class="shot">
<div class="bar"><i></i><i></i><i></i><span class="url">portal.autopilotmonitor.com/dashboard</span></div>
<img src="social-preview-dashboard.png" alt="">
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+18 -6
View File
@@ -1,8 +1,8 @@
# CodeQL configuration.
#
# The default query suites stay active — this file only filters out two C# queries
# that fire structurally on this codebase, where the flagged behavior is the feature,
# not a defect:
# The default query suites stay active — this file only filters out queries that
# fire structurally on this codebase, where the flagged behavior is the feature
# or the protection exists in a form the query cannot see:
#
# cs/exposure-of-sensitive-information
# Audit and operational logs record WHO performed an action (actor UPN / email)
@@ -15,10 +15,20 @@
# key. The IP is an identifier for throttling, not a secret at rest; storing it in
# the in-memory / table rate-limit key is by design.
#
# js/missing-rate-limiting
# Only the MCP server has Express routes. Every one that does work is throttled by
# our own limiters (oauthRateLimit / oauthTokenRateLimit on /oauth/*, accessGuard
# on /mcp); /health and the OAuth discovery documents return static JSON.
# The query only recognizes limiters from npm packages (express-rate-limit and
# similar) and flags a route handler as soon as it calls a function named
# like *auth*, *login* or *verify*. So it reports throttled and unthrottled
# routes the same way, and every alert it ever raised was a false positive. The
# coverage check it stood in for is a test: every OAuth router route must carry
# a limiter or be named as exempt (MCP oauth-rate-limit.test.ts).
#
# Genuine one-off false positives from OTHER queries (e.g. an open-redirect the taint
# model can't see is allow-listed, a rate-limited route it doesn't recognize) are
# dismissed individually in the Security tab so those queries stay sharp — they are
# NOT suppressed here.
# model can't see is allow-listed) are dismissed individually in the Security tab so
# those queries stay sharp — they are NOT suppressed here.
#
# Log-injection (cs/log-forging) is deliberately NOT filtered: user-controlled values
# are sanitized at the sink via Helpers/LogSanitizer, so the query stays useful for
@@ -29,3 +39,5 @@ query-filters:
id: cs/exposure-of-sensitive-information
- exclude:
id: cs/cleartext-storage-of-sensitive-information
- exclude:
id: js/missing-rate-limiting
+52
View File
@@ -1,25 +1,44 @@
version: 2
# Cooldown: a version is proposed only once it has been public for 7 days, so a
# release that is yanked or found malicious within its first week never reaches a
# PR. Security updates are exempt from the cooldown by Dependabot's own rules.
#
# Grouping: every ecosystem gets one batched PR for minor/patch version
# updates and one batched PR for security updates. Groups apply to version
# updates only by default, so the security-updates groups are declared
# explicitly — otherwise every advisory lands as its own PR.
updates:
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
dotnet:
patterns: ['*']
update-types: [minor, patch]
dotnet-security:
applies-to: security-updates
patterns: ['*']
- package-ecosystem: npm
directory: /src/Web/autopilot-monitor-web
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
web:
patterns: ['*']
update-types: [minor, patch]
web-security:
applies-to: security-updates
patterns: ['*']
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
# Security updates ignore this filter, so the Security tab stays clean.
ignore:
@@ -30,18 +49,51 @@ updates:
directory: /src/McpServer/autopilot-monitor-mcp
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
mcp:
patterns: ['*']
update-types: [minor, patch]
mcp-security:
applies-to: security-updates
patterns: ['*']
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
# Security updates ignore this filter, so the Security tab stays clean.
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
# Validator tooling for rules/ (ajv-cli); combine-rules.yml installs it from this
# lockfile with contents:write in hand, so the pins must not go stale.
- package-ecosystem: npm
directory: /rules/scripts
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
rules-tooling:
patterns: ['*']
rules-tooling-security:
applies-to: security-updates
patterns: ['*']
# Every action is referenced by commit SHA with the version in a trailing comment;
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
# pins are reviewed in a PR instead of drifting silently or being bumped by hand.
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
cooldown:
default-days: 7
groups:
actions:
patterns: ['*']
actions-security:
applies-to: security-updates
patterns: ['*']
@@ -0,0 +1,51 @@
<#
.SYNOPSIS
Returns the request headers that authorize an Azure Storage REST call with Microsoft Entra.
.DESCRIPTION
One place for the storage OAuth contract shared by every publisher in this repo -- the
agent ZIP and MSI, the customer bootstrap scripts, the version manifests and the build
counters. Resource id, the az invocation, the service version and the failure message
live here so they cannot drift between call sites.
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
operator's own account for the local build. No secret is read, stored or passed around.
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
below 2017-11-09, and Table requests without a SAS must carry the header at all. 2021-08-06
is old enough to be deployed in every region and new enough for everything we send.
Callers merge the result into their own headers -- `$auth + @{ ... }` yields a new
hashtable per request, so nothing is shared across uploads:
$auth = & "$PSScriptRoot/Get-StorageAuthHeaders.ps1"
Invoke-RestMethod -Uri $url -Method Put -Headers ($auth + @{ 'x-ms-blob-type' = 'BlockBlob' }) -Body $bytes
Acquire once per step and reuse: every call shells out to az (~1s) and the token is valid
for the lifetime of a job.
.OUTPUTS
[hashtable] Authorization + x-ms-version.
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
# The data-plane resource, not management.azure.com -- a management token is accepted by
# neither the blob nor the table endpoint.
$token = az account get-access-token --resource https://storage.azure.com --query accessToken -o tsv 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw ('Could not acquire an Azure Storage token. In CI this means the azure/login step ' +
'is missing or its federated credential does not cover this ref; locally, run ' +
'`az login`. A token alone is not enough: the principal also needs Storage Blob ' +
'Data Contributor on the container it writes, and Storage Table Data Contributor ' +
'on the table -- a missing role shows up later as HTTP 403 AuthorizationPermissionMismatch.')
}
return @{
'Authorization' = "Bearer $token"
'x-ms-version' = '2021-08-06'
}
+263 -67
View File
@@ -12,52 +12,78 @@
lands a consistent set (ZIP + manifest + scripts).
Both callers run the same code. The scripts on the download alias are the live
copy the WDP bootstrap MSI (Invoke-BootstrapDownload.ps1) downloads on every
enrolling device, so "current" is a correctness property, not cosmetics.
copy the stage-1 loader downloads on every enrolling device, whether it came from
Intune or from the bootstrap MSI, so "current" is a correctness property, not
cosmetics.
Steps, in order:
1. Parse $ScriptVersion from the bootstrap source.
2. Render the -Dev variant by literal substitution of the two URL/manifest
1. Parse $ScriptVersion from the loader and the bootstrap source.
2. Render the -Dev variants by literal substitution of the URL/manifest
defaults. A missing anchor is a hard failure -- a silently un-substituted
dev script would point the dev fleet at the stable agent.
3. Version-bump guard: if the published bootstrap differs from the source
but carries the same $ScriptVersion, abort. Otherwise the docs badge,
3. Version-bump guard: if a published script differs from its source but
carries the same $ScriptVersion, abort. Otherwise the docs badge,
version.json.bootstrapVersion and the portal's "your script is outdated"
hint would all keep asserting a version that is no longer what ships.
4. Upload every script blob with Cache-Control: no-cache (they rotate in
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
and nothing may touch them afterwards or the signature breaks.
6. Upload every script blob with Cache-Control: no-cache (they rotate in
place), mirrored fail-soft to the legacy account.
5. Reconcile the two version oracles: version.json.bootstrapVersion
(read-modify-write under If-Match, so a concurrent agent release cannot
lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion.
6. Verify through the alias -- re-download each blob and compare SHA-256.
7. Reconcile the version oracles: version.json.bootstrapVersion and
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
release cannot lose its agent fields) and
AdminConfiguration.LatestBootstrapV2ScriptVersion.
8. Verify through the alias -- re-download each blob and compare SHA-256.
.PARAMETER SasToken
Container SAS for the eu storage account with write permission. Leading '?' tolerated.
Comparisons against a published script always strip its signature block first
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
every run; without stripping, the bump guard and the drift check would report a
change for every unchanged script.
.PARAMETER LegacySasToken
Container SAS for the legacy account. Optional -- a mirror failure warns, never fails.
.PARAMETER TableSasToken
Table SAS for AdminConfiguration. Optional -- omitted or 'XXX' skips the oracle update.
The legacy account lives in a different tenant, so the ambient Entra identity cannot
reach it; this is the only credential the publisher still takes.
.PARAMETER RepoRoot
Repository root. Defaults to the parent of .github/scripts.
.PARAMETER DryRun
Run every check and render, upload nothing.
Run every check and render, upload nothing. A dry run signs when a token is supplied
and skips signing when it is not: the PR gate runs this without any Azure login.
.PARAMETER SigningToken
Azure Key Vault access token for AzureSignTool. MANDATORY for a real publish -- an
unsigned customer script is not something this repo publishes. Together with
-KeyVaultUrl and -CertificateName.
.PARAMETER ExpectedPublisher
Optional -like pattern the signer subject must match after signing. Pins the publisher
instead of accepting any trusted signature; the loader on the device pins the same way.
.PARAMETER SignedOutputDir
Optional directory that receives a copy of every published file exactly as uploaded.
Used by CI to offer the signed scripts as a build artifact for local inspection.
.EXAMPLE
./Publish-BootstrapScripts.ps1 -DryRun
.EXAMPLE
./Publish-BootstrapScripts.ps1 -SasToken $env:SAS -TableSasToken $env:TABLE_SAS
./Publish-BootstrapScripts.ps1 -LegacySasToken $env:LEGACY_SAS -SigningToken $env:AKV_TOKEN `
-KeyVaultUrl $env:KV_URL -CertificateName $env:CERT
#>
[CmdletBinding()]
param(
[string]$SasToken,
[string]$LegacySasToken,
[string]$TableSasToken,
[string]$RepoRoot,
[string]$SigningToken,
[string]$KeyVaultUrl,
[string]$CertificateName,
[string]$TimestampUrl = 'http://timestamp.acs.microsoft.com',
[string]$ExpectedPublisher,
[string]$SignedOutputDir,
[switch]$DryRun
)
@@ -66,9 +92,6 @@ $ErrorActionPreference = 'Stop'
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
}
if (-not $DryRun -and [string]::IsNullOrWhiteSpace($SasToken)) {
throw 'SasToken is required unless -DryRun is used.'
}
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
@@ -78,8 +101,23 @@ $TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminCo
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
$DevBlob = 'Install-AutopilotMonitor-Dev.ps1'
# Stage 1. Assigned in Intune once, and embedded in the bootstrap MSI; it downloads the
# bootstrap above on every device and verifies its publisher before running it.
$LoaderSource = Join-Path $RepoRoot 'scripts/Bootstrap/Start-AutopilotMonitor.ps1'
$LoaderBlob = 'Start-AutopilotMonitor.ps1'
$LoaderDevBlob = 'Start-AutopilotMonitor-Dev.ps1'
$ScriptContentType = 'text/plain; charset=utf-8'
$SigningEnabled = -not [string]::IsNullOrWhiteSpace($SigningToken)
if (-not $DryRun -and -not $SigningEnabled) {
throw ('Publishing requires -SigningToken (plus -KeyVaultUrl and -CertificateName): the ' +
'loader on every device verifies the publisher of what it downloads, so an unsigned ' +
'script would be rejected in the field. Only -DryRun may run without signing.')
}
if ($SigningEnabled -and ([string]::IsNullOrWhiteSpace($KeyVaultUrl) -or [string]::IsNullOrWhiteSpace($CertificateName))) {
throw '-SigningToken needs -KeyVaultUrl and -CertificateName.'
}
function Get-HttpStatus {
param($ErrorRecord)
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
@@ -125,6 +163,132 @@ function Get-PublishBytes {
return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised)
}
# A PowerShell signature is appended as a comment block at the end of the file, so the
# published bytes are "source + signature". Everything that compares a published script to
# its source has to cut that block off first -- the signature carries a timestamp and is
# therefore different on every publish, while the script above it is unchanged.
function Remove-SignatureBlock {
param([string]$Text)
$marker = '# SIG # Begin signature block'
$index = $Text.IndexOf($marker)
if ($index -lt 0) { return $Text }
return $Text.Substring(0, $index)
}
# Comparison form: no signature, LF, no trailing blank lines. Used for "is the published
# copy still this source" -- never for what gets uploaded.
function Get-ComparableText {
param([string]$Text)
return (Remove-SignatureBlock $Text).Replace("`r`n", "`n").TrimEnd("`n")
}
# Signs the publish set in place: each item's bytes are written to a work directory, signed
# there, verified, and read back as the bytes that will be uploaded. Signing has to be the
# last transformation -- the signature covers exactly these bytes, so nothing may normalise
# or re-encode them afterwards.
function Set-PublishSetSignature {
param([object[]]$Items, [string]$WorkDir)
if (-not (Get-Command azuresigntool -ErrorAction SilentlyContinue)) {
throw "azuresigntool is not on PATH. Install it with: dotnet tool install --global AzureSignTool --version 7.0.1"
}
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$paths = @()
foreach ($item in $Items) {
$path = Join-Path $WorkDir $item.BlobName
[System.IO.File]::WriteAllBytes($path, [byte[]]$item.Bytes)
$paths += $path
}
Write-Host "Signing $($paths.Count) script(s) with certificate '$CertificateName'..."
& azuresigntool.exe sign --verbose `
--azure-key-vault-url $KeyVaultUrl `
--azure-key-vault-accesstoken $SigningToken `
--azure-key-vault-certificate $CertificateName `
--timestamp-rfc3161 $TimestampUrl `
--file-digest sha256 `
--description 'Autopilot Monitor bootstrap' `
--description-url 'https://www.autopilotmonitor.com' `
$paths
# Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing
# failure would publish unsigned scripts that every loader in the field then rejects.
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed with exit code $LASTEXITCODE -- nothing was uploaded."
}
foreach ($item in $Items) {
$path = Join-Path $WorkDir $item.BlobName
$sig = Get-AuthenticodeSignature -FilePath $path
if ($sig.Status -ne 'Valid') {
throw "Signature on $($item.BlobName) is '$($sig.Status)', expected 'Valid'."
}
# Without a timestamp the signature dies with the certificate, and these scripts sit
# on customer devices far longer than a certificate lifetime.
if (-not $sig.TimeStamperCertificate) {
throw "Signature on $($item.BlobName) carries no timestamp."
}
if ($ExpectedPublisher -and $sig.SignerCertificate.Subject -notlike $ExpectedPublisher) {
throw "$($item.BlobName) was signed by '$($sig.SignerCertificate.Subject)', expected '$ExpectedPublisher'."
}
# Plain assignment, NO comma operator: the ',' elsewhere in this file exists because
# PowerShell unrolls an array RETURNED from a function, which is not what a property
# assignment does -- here it would wrap the byte[] in an Object[] of length 1 and the
# upload would fail converting it back.
$signedBytes = [System.IO.File]::ReadAllBytes($path)
if ($signedBytes.Length -le $item.Bytes.Length) {
throw ("Signed $($item.BlobName) is $($signedBytes.Length) bytes but the unsigned form " +
"was $($item.Bytes.Length) -- a signature only ever adds bytes, so this is a " +
'read-back bug, not a signing result.')
}
$item.Bytes = $signedBytes
Write-Host " signed $($item.BlobName) ($($signedBytes.Length) bytes, signer $($sig.SignerCertificate.Subject.Split(',')[0]))"
}
}
# Aborts when a published script changed behaviour without a version bump. Consumers act on
# the version (docs badge, version.json, the portal's outdated-script hint), so an unchanged
# version has to mean unchanged behaviour.
function Assert-VersionBump {
param(
[string]$BlobName,
[string]$SourceContent,
[string]$SourceOrigin,
[string]$SourceVersion
)
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BlobName"
if ($null -eq $publishedBytes) {
Write-Host "No published $BlobName yet -- version-bump guard skipped (first publish)"
return
}
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
if ((Get-ComparableText $publishedText) -eq (Get-ComparableText $SourceContent)) {
Write-Host "Published $BlobName is already this source (signature block aside)"
return
}
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BlobName" -Optional
if ($null -eq $publishedVersion) {
Write-Host "::warning::The published $BlobName is not a readable script (no ScriptVersion found). Republishing over it."
} elseif ($publishedVersion -ne $SourceVersion) {
Write-Host "Version bump: $publishedVersion -> $SourceVersion ($BlobName)"
} elseif ((Get-CodeFingerprint -Text (Remove-SignatureBlock $publishedText) -Origin "$AliasUrl/$BlobName") -eq
(Get-CodeFingerprint -Text $SourceContent -Origin $SourceOrigin)) {
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
Write-Host "::warning::$BlobName changed in comments or formatting only; publishing under the unchanged version $SourceVersion."
} else {
throw ("Version-bump guard: $BlobName changed behaviour but ScriptVersion is still $SourceVersion. " +
"Bump it in $SourceOrigin -- the docs badge, version.json and the " +
'portal outdated-script hint all read that value, and customers decide from it whether ' +
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
}
}
function Get-BootstrapScriptVersion {
param([string]$Content, [string]$Origin, [switch]$Optional)
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
@@ -152,12 +316,16 @@ function Get-CodeFingerprint {
return (($tokens | Where-Object { $ignored -notcontains $_.Kind.ToString() } | ForEach-Object { $_.Text }) -join "`n")
}
# ------------------------------------------------------------------ 1. source
# ------------------------------------------------------------------ 1. sources
$sourceContent = Get-Content $BootstrapSource -Raw
$sourceBytes = Get-PublishBytes $sourceContent
$scriptVersion = Get-BootstrapScriptVersion -Content $sourceContent -Origin $BootstrapSource
Write-Host "Bootstrap script version: $scriptVersion"
$loaderContent = Get-Content $LoaderSource -Raw
$loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $LoaderSource
Write-Host "Loader script version: $loaderVersion"
# ------------------------------------------------------------------ 2. dev render
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
@@ -170,33 +338,21 @@ if ($sourceContent.IndexOf($stableUrlLiteral) -lt 0 -or $sourceContent.IndexOf($
$devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace($stableManLiteral, $devManLiteral)
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
# ------------------------------------------------------------------ 3. bump guard
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BootstrapBlob"
if ($null -eq $publishedBytes) {
Write-Host 'No published bootstrap yet -- version-bump guard skipped (first publish)'
} elseif ((Get-Sha256 $publishedBytes) -eq (Get-Sha256 $sourceBytes)) {
Write-Host 'Published bootstrap is already byte-identical to the source'
} else {
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional
# The dev loader must fetch the dev bootstrap, or a dev device would run the stable chain.
$stableLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
$devLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1"'
if ($null -eq $publishedVersion) {
Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it."
} elseif ($publishedVersion -ne $scriptVersion) {
Write-Host "Version bump: $publishedVersion -> $scriptVersion"
} elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq
(Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) {
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
Write-Host "::warning::$BootstrapBlob changed in comments or formatting only; publishing under the unchanged version $scriptVersion."
} else {
throw ("Version-bump guard: $BootstrapBlob changed behaviour but ScriptVersion is still $scriptVersion. " +
"Bump it in $BootstrapSource -- the docs badge, version.json.bootstrapVersion and the " +
'portal outdated-script hint all read that value, and customers decide from it whether ' +
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
}
if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) {
throw "Loader dev-render: anchor literal missing in $LoaderSource"
}
$devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiteral)
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
# ------------------------------------------------------------------ 4. upload
# ------------------------------------------------------------------ 3. bump guards
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
# ------------------------------------------------------------------ 4. publish set
# Sources beyond the bootstrap pair, read straight from the repo.
$extraSources = @(
@{ Path = 'scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1'; BlobName = 'Test-ShouldBootstrapAgent.ps1' }
@@ -206,20 +362,38 @@ $extraSources = @(
# Uninstall-AutopilotMonitor.ps1 is deliberately NOT published: nothing links it, and
# an unauthenticated uninstall script on the public download host is not a feature.
# Text is the unsigned publish form and stays the reference for every comparison against a
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
$publishSet = [System.Collections.Generic.List[object]]::new()
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Bytes = $sourceBytes })
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Bytes = (Get-PublishBytes $devContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
foreach ($extra in $extraSources) {
$extraPath = Join-Path $RepoRoot $extra.Path
if (-not (Test-Path $extraPath)) {
throw "Publish source missing: $extraPath"
}
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Bytes = (Get-PublishBytes (Get-Content $extraPath -Raw)) })
$extraText = Get-Content $extraPath -Raw
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Text = $extraText; Bytes = (Get-PublishBytes $extraText) })
}
# ------------------------------------------------------------------ 5. sign
# After this point the bytes are final. Normalising, re-encoding or rewriting a line ending
# would invalidate the signature, and the device-side loader would refuse the script.
if ($SigningEnabled) {
Set-PublishSetSignature -Items $publishSet -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-$PID")
} else {
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
}
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
$writeSas = if ($SasToken) { $SasToken.TrimStart('?') } else { '' }
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
# ------------------------------------------------------------------ 6. upload
foreach ($item in $publishSet) {
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
@@ -227,11 +401,21 @@ foreach ($item in $publishSet) {
[byte[]]$bytes = $item.Bytes
$item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force
# Exactly the bytes that go on the wire, for the CI artifact. Written in a dry run too:
# that is the run an operator uses to inspect a signed script before it is published.
if ($SignedOutputDir) {
New-Item -ItemType Directory -Force -Path $SignedOutputDir | Out-Null
[System.IO.File]::WriteAllBytes((Join-Path $SignedOutputDir $item.BlobName), $bytes)
}
if ($DryRun) {
# Doubles as a drift check: says per blob whether the alias already serves this source.
# Doubles as a drift check: says per blob whether the alias already serves this
# source. Compared as text without the signature block -- signed bytes differ on
# every run, so a byte comparison would report drift for every unchanged script.
$served = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
$state = if ($null -eq $served) { 'MISSING on the alias' }
elseif ((Get-Sha256 $served) -eq $item.Sha256) { 'already current' }
elseif ((Get-ComparableText ([System.Text.Encoding]::UTF8.GetString($served))) -eq
(Get-ComparableText $item.Text)) { 'already current' }
else { 'STALE on the alias' }
Write-Host " [dry-run] $($item.BlobName) ($($bytes.Length) bytes, sha256 $($item.Sha256)) -- $state"
continue
@@ -241,8 +425,18 @@ foreach ($item in $publishSet) {
# with Front Door. Route caching is disabled there, but a stale script paired with a
# fresh manifest would fail the bootstrap SHA check -- this keeps that class of bug
# impossible even if caching is ever re-enabled.
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = $ScriptContentType; 'x-ms-blob-cache-control' = 'no-cache' }
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)?$writeSas" -Method Put -Headers $headers -Body $bytes | Out-Null
#
# Content-Disposition: without it a text/plain blob opens as a web page in every
# browser, and the documented "download the script" step silently becomes "copy this
# text". The header makes the link a download everywhere, and command-line clients
# (the loader, the MSI, curl) ignore it.
$headers = @{
'x-ms-blob-type' = 'BlockBlob'
'Content-Type' = $ScriptContentType
'x-ms-blob-cache-control' = 'no-cache'
'x-ms-blob-content-disposition' = ('attachment; filename="{0}"' -f $item.BlobName)
}
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)" -Method Put -Headers ($authHeaders + $headers) -Body $bytes | Out-Null
# Read straight back from the blob (authoritative, no CDN in the way) before touching the
# mirror or the next file. The alias verification at the end would catch a bad body too,
@@ -266,7 +460,7 @@ foreach ($item in $publishSet) {
}
}
# ------------------------------------------------------------------ 5. version oracles
# ------------------------------------------------------------------ 7. version oracles
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
# agent release cannot lose its version/sha256 fields.
@@ -278,11 +472,15 @@ if (-not $DryRun) {
} else { [string]$manifestResp.Content }
$manifest = $manifestRaw | ConvertFrom-Json
if ($manifest.bootstrapVersion -eq $scriptVersion) {
Write-Host "version.json already reports bootstrapVersion $scriptVersion"
if ($manifest.bootstrapVersion -eq $scriptVersion -and $manifest.loaderVersion -eq $loaderVersion) {
Write-Host "version.json already reports bootstrapVersion $scriptVersion and loaderVersion $loaderVersion"
} else {
$etag = '"' + ([string]($manifestResp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
$manifest | Add-Member -NotePropertyName 'bootstrapVersion' -NotePropertyValue $scriptVersion -Force
# Additive: no consumer reads loaderVersion yet. It exists so support can tell
# which stage-1 file a device was assigned -- stage 2 is always current by
# construction, so its version says nothing about the customer's Intune copy.
$manifest | Add-Member -NotePropertyName 'loaderVersion' -NotePropertyValue $loaderVersion -Force
$body = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Compress))
$manifestHeaders = @{
'x-ms-blob-type' = 'BlockBlob'
@@ -290,8 +488,8 @@ if (-not $DryRun) {
'x-ms-blob-cache-control' = 'no-cache'
'If-Match' = $etag
}
Invoke-RestMethod -Uri "$ContainerUrl/version.json?$writeSas" -Method Put -Headers $manifestHeaders -Body $body | Out-Null
Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)"
Invoke-RestMethod -Uri "$ContainerUrl/version.json" -Method Put -Headers ($authHeaders + $manifestHeaders) -Body $body | Out-Null
Write-Host "version.json bootstrapVersion -> $scriptVersion, loaderVersion -> $loaderVersion (agent fields untouched)"
if ($legacySas) {
try {
@@ -310,11 +508,8 @@ if (-not $DryRun) {
}
}
$tableSas = if ($TableSasToken) { $TableSasToken.TrimStart('?') } else { '' }
if ($DryRun) {
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
} elseif ([string]::IsNullOrEmpty($tableSas) -or $tableSas -eq 'XXX') {
Write-Host 'SKIPPED: AdminConfiguration update -- set AZURE_TABLE_ADMIN_SAS_TOKEN to enable the portal version oracle'
} else {
$tableHeaders = @{
'Content-Type' = 'application/json'
@@ -322,11 +517,11 @@ if ($DryRun) {
'If-Match' = '*'
}
$tableBody = @{ LatestBootstrapV2ScriptVersion = $scriptVersion } | ConvertTo-Json
Invoke-RestMethod -Uri "$TableUrl`?$tableSas" -Method Merge -Headers $tableHeaders -Body $tableBody | Out-Null
Invoke-RestMethod -Uri $TableUrl -Method Merge -Headers ($authHeaders + $tableHeaders) -Body $tableBody | Out-Null
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
}
# ------------------------------------------------------------------ 6. verify via alias
# ------------------------------------------------------------------ 8. verify via alias
if (-not $DryRun) {
$pending = [System.Collections.ArrayList]::new()
$publishSet | ForEach-Object { [void]$pending.Add($_) }
@@ -358,7 +553,8 @@ if ($env:GITHUB_STEP_SUMMARY) {
$summary = @(
"### Bootstrap scripts: $mode",
'',
"Bootstrap script version: **$scriptVersion**",
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
''
) + ($publishSet | ForEach-Object { "- $($_.BlobName) -- sha256 $($_.Sha256)" })
($summary -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
+9 -12
View File
@@ -12,7 +12,8 @@
A failed build burns a number, which is harmless -- uniqueness matters,
density does not.
The blob is public-read; only the write needs the container SAS.
The blob is public-read; only the write needs authorization, and that comes from the
ambient Entra identity (see Get-StorageAuthHeaders.ps1) -- no secret is passed in.
Why a counter and not the published version manifest: manifests are written
on publish, not on build (the agent writes version.json only for stable
@@ -20,10 +21,7 @@
overwrite each other's versioned artifact.
.PARAMETER CounterUrl
Full URL of the counter blob, without SAS.
.PARAMETER SasToken
Container SAS with create+write permission. A leading '?' is tolerated.
Full URL of the counter blob.
.PARAMETER Override
Explicit build number instead of counter+1. If it is not greater than the
@@ -37,20 +35,19 @@
[int] the reserved build number. Progress goes to the host, not the pipeline.
.EXAMPLE
$n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas
$n = ./Request-BuildNumber.ps1 -CounterUrl $url
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$CounterUrl,
[Parameter(Mandatory = $true)][string]$SasToken,
[int]$Override = 0,
[int]$MaxAttempts = 5
)
$ErrorActionPreference = 'Stop'
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...").
$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?')
# The write is Entra-authorized; the read below stays anonymous (public container).
$authHeaders = & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1')
function Get-HttpStatus {
param($ErrorRecord)
@@ -65,7 +62,7 @@ function Read-Counter {
$resp = Invoke-WebRequest -Uri $CounterUrl -Method Get -UseBasicParsing
} catch {
if ((Get-HttpStatus $_) -eq 404) {
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."
}
throw
}
@@ -85,8 +82,8 @@ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 }
try {
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
$headers = $authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
Invoke-RestMethod -Uri $CounterUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)"
return $target
} catch {
+103
View File
@@ -0,0 +1,103 @@
name: Workflow gates
# Two checks over the workflow files themselves:
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
# version comment (stricter than zizmor's unpinned-uses audit).
# * zizmor -- static analysis of every workflow (permissions, credential
# persistence, template injection, ...). Configuration and the
# reasoned ignores live in zizmor.yml at the repository root; the
# same run is expected locally before a workflow change is committed.
#
# Every third-party action must be referenced by a full commit SHA, never by a tag.
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
# and that code runs inside jobs that hold the code-signing identity, the storage
# credentials and a token that can push to main. A SHA is the only reference an
# upstream owner cannot change under us.
#
# Lives in its own workflow rather than in ci.yml: CI deliberately ignores changes to
# the other workflow files, so a guard placed there would stay silent for exactly the
# commits it needs to inspect. Dependabot's action-pin PRs are the main customer: they
# rewrite workflow files without anyone running zizmor locally.
#
# Like CI, this reports rather than blocks: main is deliberately unprotected, so a red
# run is a signal to act on, not a merge barrier. Making it binding means adding
# 'Verify action pins' as a required status check -- a branch-protection decision, not
# something this file can assert.
#
# The pins are not maintained by hand — .github/dependabot.yml opens a monthly grouped
# PR that rewrites both the SHA and the trailing version comment. The comment is
# therefore required too: it is what makes a pinned line reviewable.
on:
push:
branches: [main]
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
pull_request:
branches: [main]
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
workflow_dispatch:
permissions:
contents: read
jobs:
verify-pins:
name: Verify action pins
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- 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:]]*[^[: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> # vN' so the line stays reviewable."
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.
done < <(grep -nHE '^[[:space:]]*(-[[:space:]]+)?uses:' .github/workflows/*.yml \
| sed -E 's/^([^:]+):([0-9]+):[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*/\1:\2:/')
if [ "$failed" -ne 0 ]; then
echo "::error::Unpinned action references found -- fix them before this reaches main."
exit 1
fi
echo "All action references are pinned to commit SHAs."
zizmor:
name: zizmor audit
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # SARIF upload: findings land in the Code scanning tab next to CodeQL
actions: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# zizmor itself is version-pinned like every other tool here; bump it together with
# the local install so the gate and the pre-commit run agree on the finding set.
# Informational findings (template expansion of our own step outputs in pwsh) are
# visible locally but do not fail the gate.
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
version: 1.30.1
min-severity: low
+3 -1
View File
@@ -18,7 +18,9 @@ jobs:
name: Bootstrap guard tests (PS 5.1)
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install Pester 5+
shell: powershell
+251 -47
View File
@@ -27,6 +27,10 @@ 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
permissions:
contents: write
@@ -53,6 +57,13 @@ env:
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
STABLE_ZIP: AutopilotMonitor-Agent.zip
STABLE_MANIFEST: version.json
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
build:
@@ -61,14 +72,68 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# Every released build is signed; a missing variable stops the run before a build
# number is burnt.
- name: Check code-signing configuration
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Every released build must be Authenticode-signed."
}
}
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
# account, so the storage login below has to be the last one. Both federated credentials
# are bound to the ref, so this job must never be given an `environment:` -- that would
# change the OIDC subject claim and break both logins.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Every storage write below (counter blob, agent ZIP, manifests, AdminConfiguration)
# is authorized by this federated identity -- no SAS secret. One login covers the whole
# job: the federated credential is bound to the ref, and a release run takes ~2 minutes,
# well inside the token lifetime. Only the legacy mirror still needs a SAS: that account
# lives in the pre-migration tenant, where this principal has no identity.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: '10.0.x'
# Version pinned; roll-forward because the tool targets an older runtime than the SDK above.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# Build numbers come from the shared counter blob -- one counter and one
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
@@ -76,12 +141,22 @@ jobs:
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
# Input and secret reach the script only as environment variables, never as script
# text: ${{ }} is substituted before pwsh parses the block, so a crafted
# build_number could otherwise break out of the quotes and run arbitrary code.
env:
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
run: |
$override = 0
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
if ($env:BUILD_NUMBER_OVERRIDE -notmatch '^\d+$') {
throw "build_number must be a positive integer, got '$($env:BUILD_NUMBER_OVERRIDE)'"
}
$override = [int]$env:BUILD_NUMBER_OVERRIDE
}
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override
$csproj = Get-Content $env:CSPROJ_PATH -Raw
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
@@ -114,6 +189,47 @@ jobs:
- name: Run Agent unit tests
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
# Authenticode over everything we author; third-party assemblies keep their own signature.
#
# THE POSITION IS LOAD-BEARING: signing rewrites the PE file, so it must run before the
# ZIP and every SHA-256 in the next step -- those hashes are the bootstrap's integrity
# oracle, and hashes taken before signing match no downloaded file.
#
# $LASTEXITCODE is checked explicitly: PowerShell does not throw on a native non-zero exit.
- name: Sign agent binaries (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
$targets = @()
$targets += Get-ChildItem -Path "$env:AGENT_OUTPUT/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File
$targets += Get-ChildItem -Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe" -File
if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.Agent.exe')) {
throw "AutopilotMonitor.Agent.exe not found in $env:AGENT_OUTPUT -- nothing to sign."
}
if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.SummaryDialog.exe')) {
throw "AutopilotMonitor.SummaryDialog.exe not found in $env:DIALOG_OUTPUT -- nothing to sign."
}
$targets | ForEach-Object { Write-Host "Signing $($_.Name)" }
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description "Autopilot Monitor Agent" `
--description-url "https://www.autopilotmonitor.com" `
($targets | ForEach-Object { $_.FullName })
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
}
- name: Create release artifact
id: artifact
shell: pwsh
@@ -204,12 +320,57 @@ jobs:
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
# Verified against the STAGING copy -- the exact bytes that ship. The invariant is "every
# AutopilotMonitor PE in the ZIP is signed", so a project added later cannot ride along
# unsigned. A missing timestamp fails too: without it the signature dies with the cert.
- name: Verify Authenticode signatures
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$files = Get-ChildItem -Path "staging/agent/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File
if ($files.Count -lt 2) {
throw "Expected signed binaries in staging/agent, found $($files.Count)."
}
$failed = $false
$thumbprints = @{}
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature -FilePath $file.FullName
if ($sig.Status -ne 'Valid') {
Write-Host "::error file=$($file.Name)::Signature status is '$($sig.Status)' -- expected 'Valid'."
$failed = $true
continue
}
if (-not $sig.TimeStamperCertificate) {
Write-Host "::error file=$($file.Name)::Signature carries no RFC-3161 timestamp -- it would expire with the certificate."
$failed = $true
continue
}
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and
$sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
Write-Host "::error file=$($file.Name)::Signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
$failed = $true
continue
}
$thumbprints[$sig.SignerCertificate.Thumbprint] = $true
Write-Host "$($file.Name): Valid, timestamped $($sig.TimeStamperCertificate.Subject.Split(',')[0]), signer $($sig.SignerCertificate.Subject)"
}
if ($thumbprints.Count -gt 1) {
Write-Host "::error::Staged binaries carry $($thumbprints.Count) different signing certificates -- expected exactly one."
$failed = $true
}
if ($failed) {
throw "Signature verification failed. Nothing was published."
}
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
run: |
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
# Legacy mirror (transition until customers moved to the download.-alias):
# blobs are ALSO pushed to the old account so the bootstrap URL baked into
# customers' Intune scripts keeps serving current bits. Fail-soft -- a
@@ -219,7 +380,6 @@ jobs:
function Send-Blob {
param([string]$Local, [string]$BlobName, [string]$ContentType)
$url = "$containerUrl/$BlobName`?$sasToken"
$bytes = [System.IO.File]::ReadAllBytes($Local)
# no-cache on EVERY blob: they all rotate in place (even the per-line ZIP is
# overwritten each build), and the download alias fronts them with Front Door.
@@ -233,7 +393,7 @@ jobs:
$md5.Dispose()
}
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1MB, 2)) MB)..."
Invoke-RestMethod -Uri $url -Method Put -Headers $headers -Body $bytes
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
Write-Host " uploaded $BlobName"
if (-not [string]::IsNullOrEmpty($legacySasToken)) {
try {
@@ -259,79 +419,90 @@ jobs:
# to main, so a release lands a set that is consistent with what is already live
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
# Signed with the same identity: the device-side loader verifies the publisher
# of what it downloads, and the publisher refuses to upload unsigned scripts.
./.github/scripts/Publish-BootstrapScripts.ps1 `
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}'
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT
}
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
# must never point at a build that is not actually in the stable namespace.
# LatestBootstrapV2ScriptVersion is NOT set here: it belongs to the script publisher
# above, which also owns it on script-only pushes. One writer per field.
$tableSasToken = "${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}".TrimStart('?')
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
} elseif ([string]::IsNullOrEmpty($tableSasToken) -or $tableSasToken -eq "XXX") {
Write-Host "SKIPPED: Table SAS token not configured -- set AZURE_TABLE_ADMIN_SAS_TOKEN secret to enable backend hash-oracle"
} else {
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
$agentVersion = "${{ steps.version.outputs.version }}"
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
$tableBody = @{
LatestAgentV2Version = $agentVersion
LatestAgentV2Sha256 = $sha256
LatestAgentV2ExeSha256 = $exeSha256
}
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers ($authHeaders + @{
"Content-Type" = "application/json"
"Accept" = "application/json;odata=nometadata"
"If-Match" = "*"
} -Body ($tableBody | ConvertTo-Json)
}) -Body ($tableBody | ConvertTo-Json)
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
}
# Sigstore keyless provenance: binds the ZIP digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log. The digest covers the identical
# Sigstore keyless provenance: binds the artifact digests to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log. The digests cover the identical
# bytes uploaded to blob (versioned AND stable), so one attestation covers all copies.
# Verify: gh attestation verify <zip> --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (ZIP)
uses: actions/attest-build-provenance@v4
# Complements Authenticode rather than replacing it: the signature says who published a
# binary, the attestation which build produced these exact bytes. Both EXEs are subjects
# of their own so an extracted binary can be verified without the ZIP.
# Verify: gh attestation verify <file> --repo okieselbach/AutopilotMonitor
# Skipped on runs that publish nothing -- no log entry for bytes nobody receives.
- name: Attest build provenance
if: inputs.upload_blob || inputs.create_release
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
with:
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
subject-path: |
${{ steps.artifact.outputs.versioned_zip_release }}
staging/agent/AutopilotMonitor.Agent.exe
staging/agent/AutopilotMonitor.SummaryDialog.exe
- name: Upload build artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload version metadata
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
# First-party `gh` instead of a third-party action: this job holds contents:write,
# id-token:write and attestations:write, so no code we do not control runs here.
# The "What's new" section comes ONLY from the release_notes input -- CI never
# composes customer-facing text from commits. An empty input writes a generic
# fallback that links to the docs changelog and carries the HTML-comment marker
# below; the operator tooling scans release bodies for that marker to find the
# last release with real notes, so the next noted release covers the gap.
# Never rename the marker. --generate-notes is not used: it lists pull requests
# only (Dependabot noise); the compare link to the previous tag replaces it.
- name: Create GitHub Release
if: inputs.create_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
body: |
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
### Installation
1. Download `AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip`
2. Extract to `C:\ProgramData\AutopilotMonitor\Agent\`
3. Run `AutopilotMonitor.Agent.exe --install` to register the Scheduled Task
### Included Components
- **AutopilotMonitor.Agent.exe** - Monitoring agent (V2 release line)
- **AutopilotMonitor.SummaryDialog.exe** - Enrollment summary dialog (WPF)
- **version-v2.json** - Per-line version manifest
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
RELEASE_NOTES: ${{ inputs.release_notes }}
RELEASE_BODY_TAIL: |
### Version Info
- **Version:** ${{ steps.version.outputs.version }}
- **Build Number:** ${{ steps.version.outputs.build_number }}
@@ -340,13 +511,46 @@ jobs:
- **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. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
```
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
draft: false
prerelease: ${{ inputs.prerelease }}
files: |
${{ steps.artifact.outputs.versioned_zip_release }}
${{ env.MANIFEST_NAME }}
generate_release_notes: true
run: |
$ErrorActionPreference = 'Stop'
$notes = "$env:RELEASE_NOTES".Trim()
if ($notes -eq '') {
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
}
# Previous release for the compare link. Fail-soft: the first release has none,
# and a listing hiccup must never fail a release.
$prevTag = ''
$eap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$prevTag = "$(gh release list --repo $env:GITHUB_REPOSITORY --limit 1 --exclude-drafts --json tagName -q '.[0].tagName' 2>$null)".Trim()
} catch { $prevTag = '' }
$ErrorActionPreference = $eap
$compare = ''
if ($prevTag -ne '' -and $prevTag -ne $env:RELEASE_TAG) {
$compare = "`n`n**Full changelog:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:RELEASE_TAG`n"
}
$body = "### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
Set-Content -Path $notesFile -Value $body -Encoding utf8 -NoNewline
$ghArgs = @(
'release', 'create', $env:RELEASE_TAG,
$env:RELEASE_ZIP, $env:MANIFEST_NAME,
'--repo', $env:GITHUB_REPOSITORY,
'--target', $env:GITHUB_SHA,
'--title', $env:RELEASE_NAME,
'--notes-file', $notesFile
)
if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' }
& gh @ghArgs
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
Write-Host "Release $env:RELEASE_TAG created"
+128 -16
View File
@@ -1,11 +1,11 @@
name: Build & Publish Bootstrap MSI
# Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI carries no bootstrap
# logic (only the runner script that downloads the CURRENT server-hosted
# Install-AutopilotMonitor.ps1), so it is rebuilt rarely: only when the runner script
# or the WiX package changes. Version lives in Package.wxs — bump it there before
# dispatching so Intune LOB update detection sees a new ProductVersion.
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI is packaging only: it
# carries the SAME stage-1 loader that is assigned directly in Intune, so it is rebuilt
# rarely — only when that loader or the WiX package changes. Version lives in Package.wxs
# — bump it there before dispatching so Intune LOB update detection sees a new
# ProductVersion.
on:
workflow_dispatch:
@@ -23,8 +23,15 @@ permissions:
env:
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1
RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps.
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
build:
@@ -33,10 +40,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# The runner executes as SYSTEM on every enrolling device — a parse error or
# The loader executes as SYSTEM on every enrolling device — a parse error or
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
# stands on its own because it can be dispatched at any time.
- name: Validate runner script (parse + ASCII-only)
shell: pwsh
run: |
@@ -62,8 +73,77 @@ jobs:
Write-Host "MSI version (from Package.wxs): $version"
"version=$version" >> $env:GITHUB_OUTPUT
# Every published MSI is signed; a missing variable stops the run before the build.
- name: Check code-signing configuration
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
}
}
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
# account, so the storage login below has to be the last one. Both federated credentials
# are bound to the ref, so this job must never be given an `environment:` -- that would
# change the OIDC subject claim and break both logins.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# The blob upload below is authorized by this federated identity, not by a SAS.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
# install, so a compromised or simply broken point release must not flow into a build
# unnoticed. Bumping is a reviewed commit.
- name: Install WiX toolset
run: dotnet tool install --global wix --version "5.*"
run: dotnet tool install --global wix --version 5.0.2
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards
# would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need
# the embedded loader to carry a signature of its own.
- name: Sign bootstrap loader (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor bootstrap runner' `
--description-url 'https://www.autopilotmonitor.com' `
$env:RUNNER_SCRIPT
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
- name: Build MSI
shell: pwsh
@@ -75,16 +155,46 @@ jobs:
wix build Package.wxs -arch x64 -o $out
Pop-Location
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
$sha256 = (Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
Write-Host "SHA-256: $sha256"
# Signing rewrites the MSI, so it runs before the hash and the upload. A missing
# timestamp fails the run: without it the signature dies with the certificate.
- name: Sign MSI (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor Bootstrap' `
--description-url 'https://www.autopilotmonitor.com' `
$env:STABLE_MSI
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
# The MSI and the loader it carries -- verified here, before anything is published.
foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) {
$sig = Get-AuthenticodeSignature -FilePath $file
if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." }
if (-not $sig.TimeStamperCertificate) { throw "Signature on $file carries no timestamp." }
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "$file was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
Write-Host "$file : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
}
Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
run: |
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$version = "${{ steps.version.outputs.version }}"
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
@@ -102,7 +212,7 @@ jobs:
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
$md5.Dispose()
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1KB)) KB)..."
Invoke-RestMethod -Uri "$containerUrl/$BlobName`?$sasToken" -Method Put -Headers $headers -Body $bytes
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
Write-Host " uploaded $BlobName"
}
@@ -125,15 +235,17 @@ jobs:
}
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
# Sigstore keyless provenance, same as the agent ZIP. Verify:
# Sigstore keyless provenance, same as the agent ZIP; covers the signed bytes. Skipped
# on runs that publish nothing -- no log entry for bytes nobody receives. Verify:
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (MSI)
uses: actions/attest-build-provenance@v4
if: inputs.upload_blob
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
with:
subject-path: ${{ env.STABLE_MSI }}
- name: Upload build artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
path: ${{ env.STABLE_MSI }}
+120 -14
View File
@@ -7,12 +7,82 @@ name: CI
# The deploy workflows build but never test, so this is the only place the suites
# actually run. It does not gate anything — main is deliberately unprotected, so a
# red run reports rather than blocks. Read the result and follow up.
#
# Path filters: commits that only bump a private submodule gitlink (internal/, infra/,
# .claude/commands), touch prose or edit another workflow cannot change anything the
# suites cover, so they are skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
# path ('internal', no trailing slash), which 'internal/**' does NOT match — both forms
# are listed. GitHub skips only when EVERY changed path matches; a commit that touches
# docs AND code still runs.
on:
push:
branches: [main]
paths-ignore:
- 'internal'
- 'internal/**'
- 'infra'
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/codeql/**'
- '.github/scripts/**'
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
- '.github/workflows/action-pins.yml'
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/codeql.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
branches: [main]
paths-ignore:
- 'internal'
- 'internal/**'
- 'infra'
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/codeql/**'
- '.github/scripts/**'
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
- '.github/workflows/action-pins.yml'
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/codeql.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
workflow_dispatch:
permissions:
@@ -28,9 +98,11 @@ jobs:
# windows-latest is required: the agent test projects target net48.
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: '10.0.x'
@@ -41,16 +113,16 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release --no-restore
- name: Backend tests
run: dotnet test src/Backend/AutopilotMonitor.Functions.Tests --configuration Release --no-build --verbosity quiet
run: dotnet test src/Backend/AutopilotMonitor.Functions.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
- name: Agent V2 Core tests
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --no-build --verbosity quiet
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
- name: Decision Core tests
run: dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests --configuration Release --no-build --verbosity quiet
run: dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
- name: Summary Dialog tests
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
web:
name: Web (typecheck + tests)
@@ -59,9 +131,11 @@ jobs:
run:
working-directory: src/Web/autopilot-monitor-web
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
@@ -93,9 +167,11 @@ jobs:
run:
working-directory: src/McpServer/autopilot-monitor-mcp
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
@@ -113,7 +189,7 @@ jobs:
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
# is honored by vector-search-provider.ts.
- name: Cache embedding model
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ github.workspace }}/.hf-cache
key: hf-model-Xenova-all-MiniLM-L6-v2
@@ -127,9 +203,11 @@ jobs:
name: Generated guardrails match rules/guardrails.json
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
@@ -143,3 +221,31 @@ jobs:
git diff --stat
exit 1
fi
shared-manifests-in-sync:
name: Generated wire types match shared-manifests.json
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
# JSON ↔ C# parity is the dotnet job's SharedManifestParityTests; this job pins
# JSON ↔ generated TS (shared-manifests.generated.ts, wire-types.generated.ts and the
# MCP's wire-types + wire-vocabularies copies - the vocabularies are what the MCP
# tool input schemas derive their z.enum() from).
- name: Regenerate
run: node scripts/generate-shared-manifest-types.js
working-directory: src/Web/autopilot-monitor-web
- name: Fail if the generated files drifted
run: |
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
git diff --stat
exit 1
fi
+67 -10
View File
@@ -1,22 +1,77 @@
name: CodeQL
# Documentation-only changes cannot affect what CodeQL analyzes (C# and
# TypeScript), so they are filtered out rather than spending ~10 minutes to
# re-report an unchanged result. A commit that touches docs AND code still runs:
# GitHub skips only when EVERY changed path matches the filter.
# Documentation-only changes and private-submodule gitlink bumps (internal/, infra/,
# .claude/commands) cannot affect what CodeQL analyzes (C# and TypeScript), so they
# are filtered out rather than spending ~10 minutes to re-report an unchanged result.
# A gitlink bump reports the bare path ('internal', no trailing slash), which
# 'internal/**' does NOT match — both forms are listed. A commit that touches docs
# AND code still runs: GitHub skips only when EVERY changed path matches the filter.
# Same list as ci.yml except that each file leaves itself out; keep them in step.
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- 'internal'
- 'internal/**'
- 'infra'
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/scripts/**'
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- 'internal'
- 'internal/**'
- 'infra'
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/dependabot.yml'
- '.github/assets/**'
- '.github/scripts/**'
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/combine-rules.yml'
- '.github/workflows/deploy-backend.yml'
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
schedule:
# Weekly, so advisories published after a merge still surface. Deliberately
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
@@ -49,15 +104,17 @@ jobs:
build-mode: none
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
if: matrix.language == 'csharp'
with:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.3
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -68,6 +125,6 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@v4.37.3
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: /language:${{ matrix.language }}
+36 -10
View File
@@ -24,14 +24,22 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Install ajv-cli
run: npm install -g ajv-cli ajv-formats
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
# main, so an install-time script from a hijacked package would run with a token that
# can rewrite the repository. rules/scripts/package-lock.json pins ajv-cli, ajv-formats
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
# needs no install hooks.
- name: Install ajv-cli from the lockfile
working-directory: rules/scripts
run: |
npm ci --ignore-scripts
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
- name: Validate Gather Rules
run: |
@@ -54,14 +62,32 @@ jobs:
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
done
# guardrails.json values are baked verbatim into generated TypeScript that is
# auto-committed to main and shipped to the portal - the schema forbids control
# and line-terminator characters so a "data" PR can never become code.
- name: Validate Guardrails
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
- name: Combine rules into dist/
run: node rules/scripts/combine.js
# Plain git instead of a third-party action: this step runs with contents:write on
# main, so no code we do not control gets to push. Checkout leaves the job's
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
- name: Commit generated outputs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
# combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -- rules/dist/ \
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
if git diff --cached --quiet; then
echo "Generated outputs unchanged - nothing to commit"
exit 0
fi
git commit -m "chore: regenerate rules/dist + generated TS from individual files"
git push origin HEAD:main
+10 -8
View File
@@ -19,17 +19,19 @@ jobs:
name: Build and Deploy Backend
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup .NET 10
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: '10.0.x'
@@ -41,7 +43,7 @@ jobs:
id: version
shell: pwsh
run: |
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt"
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
@@ -65,7 +67,7 @@ jobs:
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
with:
app-name: ${{ env.FUNCTION_APP }}
package: ./publish
@@ -115,9 +117,9 @@ jobs:
runId = '${{ github.run_id }}'
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas"
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/backend.json"
Write-Host $manifest
+17 -14
View File
@@ -26,7 +26,9 @@ jobs:
name: Build and Deploy MCP Server
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# The published customer documentation lives in its own PUBLIC repo and is
# baked into the image as the search_docs corpus (see the Dockerfile). No
@@ -36,8 +38,9 @@ jobs:
# re-run actually rolls a new revision). /health reports the SHA captured
# below so that staleness is checkable.
- name: Check out documentation bundle
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
repository: autopilotmonitor/autopilotmonitor-docs
path: mcp-docs
@@ -46,7 +49,7 @@ jobs:
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -59,7 +62,7 @@ jobs:
id: version
shell: pwsh
run: |
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt"
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
@@ -77,7 +80,7 @@ jobs:
"short_sha=$shortSha" >> $env:GITHUB_OUTPUT
- name: Login to ACR
run: az acr login --name ${{ env.ACR_NAME }}
run: az acr login --name "$ACR_NAME"
- name: Build and push container image
run: |
@@ -87,17 +90,17 @@ jobs:
--build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \
--build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \
--build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
-t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG" \
-t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:latest" \
.
docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }} --all-tags
docker push "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME" --all-tags
- name: Deploy to Container App
run: |
az containerapp update \
--name ${{ env.CONTAINER_APP_NAME }} \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
--name "$CONTAINER_APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--image "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG"
# `az containerapp update` returns once the revision is provisioned, not once
# it serves traffic — and with minReplicas=0 the first request also pays a cold
@@ -144,9 +147,9 @@ jobs:
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas"
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/mcp.json"
Write-Host $manifest
+113 -65
View File
@@ -1,5 +1,13 @@
name: Deploy Web (Static Web App)
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
# action's container, and the exported site is attested like the agent ZIP and the MSI.
#
# PR preview environments were dropped with the action: their per-PR URLs are never
# registered as redirect URIs, so nobody could log in on them anyway.
on:
push:
branches:
@@ -8,39 +16,72 @@ on:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
permissions:
contents: read
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
env:
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
WEB_DIR: src/Web/autopilot-monitor-web
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
# only thing that keeps the deploy step's code fixed. Bump deliberately.
SWA_CLI_VERSION: 2.0.10
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy Job
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Surface it as an env var so the deploy step can gate on it — secrets
# cannot be referenced directly in an `if:` expression.
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
- name: Build And Deploy
id: builddeploy
# Skip (green) instead of failing when the token is missing (Dependabot
# PRs); real deploys on main and internal PRs still have it.
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
# Only the version manifest at the very end needs Azure; logging in here rather than
# next to that step keeps a credential problem from surfacing after a live deploy.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# The customer docs repo is the single source of the "What's new" panel: the prebuild
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
# public/whats-new.json and dates every bullet via git blame — hence the full history.
# Public repo, no token. Editing the docs does NOT update the live panel until this
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
# records which docs commit is live.
- name: Check out documentation bundle
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
repository: autopilotmonitor/autopilotmonitor-docs
path: whats-new-docs
fetch-depth: 0
- name: Resolve docs commit
id: docs
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
- name: Install dependencies
working-directory: ${{ env.WEB_DIR }}
run: npm ci
# next.config output:'export' -- `next build` writes the static site to out/
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
- name: Build
working-directory: ${{ env.WEB_DIR }}
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
# Dual app-reg window: previous app registration (unset = single-app behaviour)
@@ -55,28 +96,53 @@ jobs:
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
GITHUB_SHA: ${{ github.sha }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
run: |
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; }
# The SWA action reporting success proves the upload, not that the production host
# serves the new build — verify against the live /version.json (Cache-Control:
# no-store via staticwebapp.config.json) before publishing the version manifest.
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
id: package
run: |
archive="portal-${GITHUB_SHA::7}.tar.gz"
tar -C "$WEB_DIR/out" -czf "$archive" .
echo "archive=$archive" >> "$GITHUB_OUTPUT"
sha256sum "$archive"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (portal export)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
with:
subject-path: ${{ steps.package.outputs.archive }}
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: portal-${{ github.sha }}
path: ${{ steps.package.outputs.archive }}
# Upload only -- out/ is already built. --swa-config-location points at the app root,
# where staticwebapp.config.json lives (next export does not copy it into out/).
- name: Deploy to Static Web App
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
--env production \
--no-use-keychain
# The CLI reporting success proves the upload, not that the production host serves the
# new build -- verify against the live /version.json (Cache-Control: no-store via
# staticwebapp.config.json) before publishing the version manifest.
- name: Verify deployed portal build is live
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
@@ -100,10 +166,10 @@ jobs:
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
# Cross-component deploy record, written last and only on a verified deploy —
# same contract as versions/backend.json and versions/mcp.json.
# Cross-component deploy record, written last and only on a verified deploy --
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
# docs revision the live What's new panel was generated from (as for the MCP corpus).
- name: Publish version manifest
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$manifest = [ordered]@{
@@ -111,30 +177,12 @@ jobs:
commit = '${{ github.sha }}'.Substring(0, 7)
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "${{ env.VERSIONS_CONTAINER }}/web.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/web.json"
Write-Host $manifest
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request Job
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- name: Close Pull Request
id: closepullrequest
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
action: "close"
+3
View File
@@ -3,6 +3,9 @@ name: IndexNow Notify (Bing/Yandex)
on:
workflow_dispatch:
# The job only talks to public endpoints; it never needs a repository token.
permissions: {}
jobs:
notify:
runs-on: ubuntu-latest
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
name: Federated login + read check
steps:
- name: Login to Azure (OIDC)
uses: azure/login@v3
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+2 -1
View File
@@ -25,5 +25,6 @@ concurrency:
cancel-in-progress: true
jobs:
# $/ is the self-repository form: this repository at the running commit.
gates:
uses: ./.github/workflows/bootstrap-script-gates.yml
uses: $/.github/workflows/bootstrap-script-gates.yml
+90 -4
View File
@@ -34,23 +34,109 @@ concurrency:
group: agent-blob-publish
cancel-in-progress: false
# Authenticode for the customer scripts. Same identity and same repository variables as
# build-agent.yml -- the loader on every device pins this publisher, so the two must not
# drift apart.
env:
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
# $/ is the self-repository form: this repository at the running commit, never a
# path some earlier step could have written to the workspace.
gates:
uses: ./.github/workflows/bootstrap-script-gates.yml
uses: $/.github/workflows/bootstrap-script-gates.yml
publish:
name: Publish to the download alias
needs: gates
runs-on: windows-latest
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
# OIDC token it does not need.
permissions:
contents: read
id-token: write # OIDC federated login for the storage writes (secret-less)
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Check code-signing configuration
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Published customer scripts must be signed."
}
}
# First, and only to fetch the Key Vault token: azure/login sets the default az
# account and the storage login below has to be the one that stays. See D-223 --
# both federated credentials are bound to the ref, so this job must never carry a
# GitHub `environment:`.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Authorizes the eu blob writes and the AdminConfiguration merge. The gates job above
# runs the same publisher with -DryRun and deliberately without a login -- a dry run
# must never need Azure credentials.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Version pinned so a new or compromised NuGet release cannot flow into signing
# unreviewed; roll-forward because the tool targets an older runtime than the SDK
# preinstalled on the runner.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
- name: Publish bootstrap + customer scripts
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
./.github/scripts/Publish-BootstrapScripts.ps1 `
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}' `
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT `
-SignedOutputDir "$env:RUNNER_TEMP/signed-scripts" `
-DryRun:$dryRun
# The exact bytes that were (or, on a dry run, would have been) uploaded. A dry-run
# dispatch is therefore the way to inspect a signed script locally before it reaches
# a single customer. Kept on failure too: a failed alias verification is precisely
# when someone wants to look at the file.
- name: Upload signed scripts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: signed-scripts
path: ${{ runner.temp }}/signed-scripts
if-no-files-found: ignore
+10 -21
View File
@@ -15,7 +15,7 @@ bld/
[Ll]ogs/
# Build counter files: the authoritative counter is the shared blob
# (agent container, buildcounter-v{N}.txt, ETag-CAS — see docs/agent/build-counter-blob.md).
# (agent container, buildcounter-v{N}.txt, ETag-CAS — see internal/docs/agent/build-counter-blob.md).
# The local file is only an offline fallback for -SkipUpload/bare-dotnet smoke builds.
buildcounter.txt
@@ -114,17 +114,10 @@ tmpclaude-*-cwd
# Claude skills and scripts are now in a private submodule (.claude/commands/)
# No individual file ignores needed — the submodule handles everything
# Deployment scripts (local only — contain tokens + SAS URLs)
scripts/Deployment/
# Migration tooling (local only, vorerst) — cross-tenant storage/cert migration scripts
scripts/Migration/
# Infra: tracked. The Bicep templates carry no credentials — every secret is a
# @secure() parameter with an empty default, injected at deploy time, and image
# pull runs on managed identity. Keep it that way: never commit a resolved secret
# value or a *.parameters.json holding one.
infra/*.parameters.json
# M2 — local raw session fixtures before anonymization (plan §4 M2)
tests/fixtures/enrollment-sessions-local/
@@ -135,22 +128,15 @@ tests/fixtures/enrollment-sessions-local/
TODO.txt
tools/orphan-analysis.py
tools/export-session-fixture.mjs
tasks/
# Azure Table cleanup backups (dry-run exports + delete manifests)
# Produced by /cleanup-table skill — never commit deletion payloads
# Produced by operator tooling — never commit deletion payloads
db-backups/
plans/REFACTOR_AGENT_V2.md
.claude/scheduled_tasks.lock
.claude/plans/health-script-remediation-fix.md
# Operator-local Azure setup helper — applies lifecycle policy + container
# creation against a live storage account. Not portable across environments
# (storage account name, resource group are operator-specific) so it stays
# out of the repo. Each operator keeps their own copy.
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
internal/docs/Introduction-AutopilotMonitor.pptx
internal/docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
@@ -159,8 +145,11 @@ docs/~$Introduction-AutopilotMonitor.pptx
# changes each run) and served at /version.json from the static export.
src/Web/autopilot-monitor-web/public/version.json
# What's new payload: generated by scripts/whats-new/build-whats-new.js from the docs
# checkout on every build and served at /whats-new.json from the static export.
src/Web/autopilot-monitor-web/public/whats-new.json
# Local IME binaries scratch used by the /ime-decompile skill (MSI + msiexec-
# extracted payload per version, for grepping in binaries). Decompilates live in
# the private ime-decompiles repo, MSIs in the ime-archive blob - this stays local.
# Local IME installer scratch (MSI + extracted payload per version, for grepping
# in binaries). Operator-only, never committed.
ime-files/
+6
View File
@@ -1,3 +1,9 @@
[submodule ".claude/commands"]
path = .claude/commands
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
[submodule "docs"]
path = internal
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
[submodule "infra"]
path = infra
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
+19
View File
@@ -0,0 +1,19 @@
# Code of Conduct
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.
+17
View File
@@ -0,0 +1,17 @@
# Feedback and Issues
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.
| I want to… | Go to |
|---|---|
| Report a bug | [New issue → Bug report](https://github.com/okieselbach/AutopilotMonitor/issues/new/choose) |
| Suggest a feature | [New issue → Feature request](https://github.com/okieselbach/AutopilotMonitor/issues/new/choose) |
| 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.
+659 -25
View File
@@ -1,39 +1,673 @@
Autopilot Monitor - Split License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 Autopilot Monitor Contributors
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
This project uses a split licensing model. Different components are
licensed under different terms:
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
## MIT License
Preamble
The following components are licensed under the MIT License:
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
- src/Agent/ - The Autopilot Monitor Agent (deployed to devices)
- src/Shared/ - Shared DTOs, enums, and constants
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains
free software for all its users.
See src/Agent/LICENSE and src/Shared/LICENSE for the full MIT license text.
When we speak of free software, we are referring to freedom, not price.
Our General Public Licenses are designed to make sure that you have the
freedom to distribute copies of free software (and charge for them if
you wish), that you receive source code or can get it if you want it,
that you can change the software or use pieces of it in new free
programs, and that you know you can do these things.
## GNU Affero General Public License v3.0 (AGPL-3.0)
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
The following components are licensed under the AGPL-3.0:
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
- src/Backend/ - Azure Functions backend
- src/Web/ - Next.js web frontend
- src/McpServer/ - MCP Server
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
See src/Backend/LICENSE for the full AGPL-3.0 license text.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing
under this license.
## Why Split Licensing?
The precise terms and conditions for copying, distribution and
modification follow.
The Agent runs on end-user devices and is licensed under MIT to allow
unrestricted use, modification, and distribution without copyleft
obligations.
TERMS AND CONDITIONS
The server-side components (Backend, Web, McpServer) are licensed under
AGPL-3.0 to ensure that modifications to the server infrastructure
remain open source, especially when deployed as a network service.
0. Definitions.
The Shared library is MIT-licensed because it is a dependency of the
MIT-licensed Agent. Using AGPL for Shared would effectively make the
Agent AGPL as well.
"This License" refers to version 3 of the GNU Affero General Public
License.
"Copyright" also means copyright-like laws that apply to other kinds
of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of
an exact copy. The resulting work is called a "modified version" of
the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user
through a computer network, with no transfer of a copy, is not
conveying.
An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form
of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that is
widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same
work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having
them make modifications exclusively for you, or provide you with
facilities for running those works, provided that you comply with the
terms of this License in conveying all material for which you do not
control copyright. Those thus making or running the covered works for
you must do so exclusively on your behalf, under your direction and
control, on terms that prohibit them from making any copies of your
copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such
circumvention is effected by exercising rights under this License with
respect to the covered work, and you disclaim any intention to limit
operation or modification of the work as a means of enforcing, against
the work's users, your or third parties' legal rights to forbid
circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the
code; keep intact all notices of the absence of any warranty; and give
all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section
4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not used
to limit the access or legal rights of the compilation's users beyond
what the individual works permit. Inclusion of a covered work in an
aggregate does not cause this License to apply to the other parts of
the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of
sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in
accord with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to
the Corresponding Source in the same way through the same place
at no further charge. You need not require recipients to copy
the Corresponding Source along with the object code. If the
place to copy the object code is a network server, the
Corresponding Source may be on a different server (operated by
you or a third party) that supports equivalent copying
facilities, provided you maintain clear directions next to the
object code saying where to find the Corresponding Source.
Regardless of what server hosts the Corresponding Source, you
remain obligated to ensure that it is available for as long as
needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or
updates for a work that has been modified or installed by the
recipient, or for the User Product in which it has been modified or
installed. Access to a network may be denied when the modification
itself materially and adversely affects the operation of the network
or violates the rules and protocols for communication across the
network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright
holders of that material) supplement the terms of this License with
terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally,
unless and until the copyright holder explicitly and finally terminates
your license, and (b) permanently, if the copyright holder fails to
notify you of the violation by some reasonable means prior to 60 days
after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the
patent license to downstream recipients. "Knowingly relying" means
you have actual knowledge that, but for the patent license, your
conveying the covered work in a country, or your recipient's use of
the covered work in a country, would infringe one or more identifiable
patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically
granted under this License. You may not convey a covered work if you
are a party to an arrangement with a third party that is in the
business of distributing software, under which you make payment to the
third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey
a covered work so as to satisfy simultaneously your obligations under
this License and any other pertinent obligations, then as a
consequence you may not convey it at all. For example, if you agree
to terms that obligate you to collect a royalty for further conveying
from those to whom you convey the Program, the only way you could
satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public
License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your
version supports such interaction) an opportunity to receive the
Corresponding Source of your version by providing access to the
Corresponding Source from a network server at no charge, through some
standard or customary means of facilitating copying of software. This
Corresponding Source shall include the Corresponding Source for any
work covered by version 3 of the GNU General Public License that is
incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Affero General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever
published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions
of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see
<https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper
mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application,
its interface could display a "Source" link that leads users to an
archive of the code. There are many ways you could offer source, and
different solutions will be better for different programs; see section
13 for the specific requirements.
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, and how to apply and follow
the GNU AGPL, see <https://www.gnu.org/licenses/>.
+65 -18
View File
@@ -1,46 +1,93 @@
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
# <img src=".github/assets/logo.svg" height="28" alt="Autopilot Monitor logo" /> Autopilot Monitor
[![Status](https://img.shields.io/badge/status-live-brightgreen)](https://www.autopilotmonitor.com)
[![License](https://img.shields.io/badge/license-MIT%20%2B%20AGPL--3.0-blue)](LICENSE)
[![Website](https://img.shields.io/badge/website-autopilotmonitor.com-2ea44f)](https://www.autopilotmonitor.com)
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.
The platform supports classic Windows Autopilot and modern Intune provisioning scenarios, including **Windows Autopilot Device Preparation**.
## 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.
<p align="center">
<img src="docs/images/SessionList.png" width="45%" />
<img src="docs/images/FleetHealth.png" width="45%" />
<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" />
</p>
<p align="center">
<img src="docs/images/SessionDetails.png" width="45%" />
<img src="docs/images/SessionTimeline.png" width="45%" />
<img src=".github/assets/SessionDetails.png" width="45%" alt="Autopilot Monitor session details showing device information, diagnostics, apps, policies, and enrollment status" />
<img src=".github/assets/SessionTimeline.png" width="45%" alt="Windows Autopilot enrollment timeline showing ESP phases, application installs, policies, events, and errors" />
</p>
## Overview
## Key Features
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
- **On-demand diagnostics** — request diagnostics packages from enrolled devices remotely
- **Fleet health analytics** — success rates, deployment duration, device models, applications, errors, and recurring issues
- **Session reporting** — inspect and share detailed enrollment information for individual devices
- **Windows Autopilot Device Preparation support** — monitor Device Preparation deployments alongside classic Autopilot scenarios
- **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.
+37
View File
@@ -0,0 +1,37 @@
# Security Policy
## Reporting a vulnerability
Please report security issues **privately** through
[GitHub Security Advisories](https://github.com/okieselbach/AutopilotMonitor/security/advisories/new).
Private vulnerability reporting is enabled, so a report reaches the maintainers without ever being public.
Please do not open a public issue, post on social media, or send email for security findings.
## What to expect
- An acknowledgement and an initial severity assessment.
- A fix or a mitigation plan, with a shared view of timing before anything is disclosed.
- Credit in the advisory if you want it, and none if you prefer to stay anonymous.
Reports are read by the maintainers directly. There is no guaranteed response time and no bug bounty.
## Scope and rules of engagement
Security research against the hosted service is welcome and is not a violation of the
[Terms of Use](https://www.autopilotmonitor.com/terms). While testing, please:
- do not access data of tenants you do not own,
- do not degrade the service for others,
- do not run automated scanners against production.
## Supported versions
The hosted service always runs the current `main`. For the on-device agent, only the
[latest release](https://github.com/okieselbach/AutopilotMonitor/releases/latest) receives fixes.
## More
The [Security FAQ](https://docs.autopilotmonitor.com/trust-and-security/security-faq) and
[Data Flows](https://docs.autopilotmonitor.com/trust-and-security/data-flows) pages describe the security model,
data handling, and isolation in detail.
-166
View File
@@ -1,166 +0,0 @@
<#
.SYNOPSIS
Build script for Autopilot Monitor solution.
.DESCRIPTION
Builds all .NET projects and optionally the web UI.
Run this after cloning or when you want to rebuild everything.
.PARAMETER Configuration
Build configuration: Debug or Release (default: Debug)
.PARAMETER IncludeWeb
Also build the web UI (requires Node.js)
.PARAMETER Clean
Clean before building
.EXAMPLE
.\build.ps1
Builds all .NET projects in Debug mode
.EXAMPLE
.\build.ps1 -Configuration Release -IncludeWeb
Builds everything in Release mode including web UI
#>
[CmdletBinding()]
param(
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",
[switch]$IncludeWeb,
[switch]$Clean
)
$ErrorActionPreference = "Stop"
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " Autopilot Monitor Build Script" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
$rootPath = $PSScriptRoot
$slnPath = Join-Path $rootPath "AutopilotMonitor.sln"
# Check prerequisites
Write-Host "[1/4] Checking prerequisites..." -ForegroundColor Yellow
# Check .NET SDK
try {
$dotnetVersion = & dotnet --version
Write-Host " ✓ .NET SDK version: $dotnetVersion" -ForegroundColor Green
}
catch {
Write-Host " ✗ .NET SDK not found. Please install .NET 10 SDK." -ForegroundColor Red
exit 1
}
# Check Node.js if building web
if ($IncludeWeb) {
try {
$nodeVersion = & node --version
Write-Host " ✓ Node.js version: $nodeVersion" -ForegroundColor Green
}
catch {
Write-Host " ✗ Node.js not found. Please install Node.js 18+." -ForegroundColor Red
exit 1
}
}
Write-Host ""
# Clean if requested
if ($Clean) {
Write-Host "[2/4] Cleaning solution..." -ForegroundColor Yellow
& dotnet clean $slnPath -c $Configuration
Write-Host " ✓ Clean completed" -ForegroundColor Green
Write-Host ""
}
else {
Write-Host "[2/4] Skipping clean (use -Clean to clean first)" -ForegroundColor Gray
Write-Host ""
}
# Restore packages
Write-Host "[3/4] Restoring NuGet packages..." -ForegroundColor Yellow
& dotnet restore $slnPath
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Restore failed" -ForegroundColor Red
exit 1
}
Write-Host " ✓ Restore completed" -ForegroundColor Green
Write-Host ""
# Build solution
Write-Host "[4/4] Building solution ($Configuration)..." -ForegroundColor Yellow
& dotnet build $slnPath -c $Configuration --no-restore
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Build failed" -ForegroundColor Red
exit 1
}
Write-Host " ✓ Build completed" -ForegroundColor Green
Write-Host ""
# Build web if requested
if ($IncludeWeb) {
Write-Host "[BONUS] Building web UI..." -ForegroundColor Yellow
$webPath = Join-Path $rootPath "src\Web\autopilot-monitor-web"
Push-Location $webPath
try {
Write-Host " Installing npm packages..." -ForegroundColor Gray
& npm install --silent
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ npm install failed" -ForegroundColor Red
exit 1
}
Write-Host " Building Next.js app..." -ForegroundColor Gray
& npm run build
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Web build failed" -ForegroundColor Red
exit 1
}
Write-Host " ✓ Web build completed" -ForegroundColor Green
}
finally {
Pop-Location
}
Write-Host ""
}
# Summary
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " Build Completed Successfully!" -ForegroundColor Green
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Output locations:" -ForegroundColor White
Write-Host " Agent: src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48\" -ForegroundColor Gray
Write-Host " Functions: src\Backend\AutopilotMonitor.Functions\bin\$Configuration\net10.0\" -ForegroundColor Gray
if ($IncludeWeb) {
Write-Host " Web UI: src\Web\autopilot-monitor-web\.next\" -ForegroundColor Gray
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor White
Write-Host " 1. Start Azurite: azurite --silent --location c:\azurite" -ForegroundColor Gray
Write-Host " 2. Start Functions: cd src\Backend\AutopilotMonitor.Functions && func start" -ForegroundColor Gray
if ($IncludeWeb) {
Write-Host " 3. Start Web UI: cd src\Web\autopilot-monitor-web && npm run dev" -ForegroundColor Gray
Write-Host " 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
}
else {
Write-Host " 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
}
-177
View File
@@ -1,177 +0,0 @@
---
type: Reference
title: Autopilot ZTD Diagnostics — Event IDs, Registry Surfaces, Endpoints, Error Codes
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/ZtdEvidence.cs
tags:
- agent
- autopilot
- ztd
- diagnostics
- event-log
- troubleshooting
timestamp: 2026-07-16T00:00:00+02:00
---
# Autopilot ZTD Diagnostics
Windows documents its own diagnostic surfaces for the Autopilot profile-download flow
(the Zero-Touch Deployment / "ZTD" service round-trip during OOBE). The agent consumes
three of them to turn the `autopilot_profile_missing` warning from a guess into an
evidence-backed verdict, and the backend rule engine uses the error-code map for
known-issue enrichment.
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
service, not by Autopilot. The event is still emitted with the full evidence payload plus
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
(`session.isCloudPc` or the event marker), the amber warning otherwise.
Consumers in this repo:
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
dump + deployment-service reachability probe on the profile-missing path.
* `ModernDeploymentTracker.cs` (agent) — continuous watcher of the same channel,
Level ≤ 3 only (Warning/Error/Critical) plus targeted Info IDs.
* Analyze rules (backend) — HRESULT/known-issue map (see [Error codes](#schema--error-codes)).
# Schema — ModernDeployment Autopilot event channel
Channel: `Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot`
(Event Viewer: Application and Services Logs → Microsoft → Windows →
ModernDeployment-Diagnostics-Provider → Autopilot).
| 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 |
| 161 | Info | Profile download succeeded. | `profile_downloaded` |
| 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. |
| `CloudAssignedOobeConfig` | Bitmap of configured OOBE settings (SkipCortanaOptIn=1, OobeUserNotLocalAdmin=2, SkipExpressSettings=4, SkipOemRegistration=8, SkipEula=16). |
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
await-enrollment paths.
## Startup error backfill (ModernDeploymentTracker)
At agent start (backfill enabled), the tracker replays Autopilot-channel **Level 1-2**
records from the last **4 hours** through the normal emission pipeline — this is what makes
pre-install errors (TPM attestation retries, ZTD errors 807/809/815/908) visible in the
normal IME deployment flow. Spam containment, in order:
1. **Errors only** — the Warning-level 100 heartbeat storm is excluded by the XPath.
2. **Harmless-ID downgrade + burst rollup** still applies to backfilled records.
3. **Cap** of 25 records per scan (newest first; the tail is logged, not emitted).
4. **RecordId watermark** (`autopilot-error-backfill.json`, advanced by both the backfill
scan and the live watcher) — restarts never re-emit records a previous run forwarded.
5. Backfilled events carry `backfilled: true` plus the original `timeCreated` in their
payload; the event's own timestamp is the emission time, so the session's `StartedAt`
(which the backend aligns to the earliest event) is not dragged backwards.
Presentation (2026-07-28, customer feedback — an unannounced wall of pre-agent Error
events read like a live failure):
- The scan buffers the (capped) record set, first emits an **Info summary event**
(`modern_deployment_log`, payload `backfillSummary: true` + `replayedCount` /
`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. |
| `0x801C03EA` | TPM attestation failed → Entra join with device token failed. | TPM/firmware issue; check attestation endpoints. |
| `0x81039001` | `E_AUTOPILOT_CLIENT_TPM_MAX_ATTESTATION_RETRY_EXCEEDED` (intermittent, "Securing your hardware"). | Retry provisioning; persistent → firmware. |
| `0x81039023` | TPM attestation failure on Windows 11 (pre-prov/self-deploy). | Fixed by KB5013943 (21H2) or later CU. |
| `0x81039024` | TPM has known vulnerabilities → attestation refused. | Update TPM firmware from OEM. |
| `0x80070490` | TPM attestation failure on AMD ASP fTPM platforms. | Update AMD firmware. |
| `0xC1036501` | Automatic MDM enrollment impossible — multiple MDM configurations in Entra ID. | Consolidate MDM configs. |
| `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. |
| `0x80180018` | MDM enrollment refused — licensing (missing Intune/EMS license) or device cap. | Check user license + device limits. |
| `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").
# Citations
* [Windows Autopilot troubleshooting FAQ](https://learn.microsoft.com/en-us/autopilot/troubleshooting-faq) — event IDs, registry values, process flow. (Checked 2026-07-16.)
* [Windows Autopilot known issues](https://learn.microsoft.com/en-us/autopilot/known-issues) — error codes, KB fixes. (Checked 2026-07-16.)
* [Windows Autopilot requirements — Networking](https://learn.microsoft.com/en-us/autopilot/requirements?tabs=networking) — endpoint list incl. `ztd.dds.microsoft.com`. (Checked 2026-07-16.)
* Re-check cadence: both Learn pages change regularly (known-issues carries an RSS feed:
`https://learn.microsoft.com/api/search/rss?search=%22Be+informed+about+known+issues+that+might+occur+during+Windows+Autopilot+deployment.%22&locale=en-us&%24filter=`).
When new event IDs / error codes appear, update `ZtdEvidence.cs`, the backend rule map,
and this document together.
-65
View File
@@ -1,65 +0,0 @@
---
type: Concept
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:
- **Counter blob**: `https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
— one per release line, analogous to `version-v{LINE}.json`, and kept in the `agent`
container next to the artifacts it numbers rather than in `versions/`. Content: the
**last used** build number as plain text. Public-read like the rest of the container
(the number is visible in `version-v{LINE}.json` anyway); writes require the SAS.
- The reserved number reaches the build as `BUILD_NUMBER` in the environment. The
csproj honors it and then skips its `PersistBuildCounter` target, so the repo file
is not touched.
- **CI manual override** (`build_number` input): numbers `<=` counter are re-builds
and leave the blob unchanged; numbers `>` counter advance it (monotonic guard).
- V2 was seeded with `1356` on 2026-07-17.
# Consequences
- `src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt` is now only an **offline
fallback** for `-SkipUpload` smoke builds and bare `dotnet build` (where the version
does not matter). It is never committed anymore; the CI step that pushed counter
bumps to `main` was removed.
- `AdminConfiguration.LatestAgentV2*` (backend hash-oracle) is updated **only on
stable cutover** (`publish_as_stable` / default `build.ps1` run without
`-VersionedOnly`/`-Dev`) — it must never point at a build that is not actually in
the stable namespace.
- CI builds additionally attest build provenance for the release ZIP via
`actions/attest-build-provenance` (Sigstore keyless, bound to the workflow OIDC
identity). The digest covers the identical bytes uploaded to blob, so one
attestation covers all copies. Verify:
`gh attestation verify <zip> --repo okieselbach/AutopilotMonitor`. Local builds
cannot produce this attestation — provenance is a CI-only property.
# Citations
- [Version Contract](../versioning.md) — the shared CAS protocol and manifest format
- `.github/workflows/build-agent.yml` — "Reserve build number (counter blob CAS)" step
- `.github/scripts/Request-BuildNumber.ps1` — the shared implementation
- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — local build path
- `src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj` —
`BUILD_NUMBER` property + `PersistBuildCounter` target
-131
View File
@@ -1,131 +0,0 @@
---
type: concept
title: CMTrace Time Resolution — Per-Line Self-Anchoring
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
timestamp: 2026-08-20
---
# Problem
A CMTrace line carries local time and no offset — IME 1.104 writes `DateTime.Now.TimeOfDay`
in both of its trace listeners. Converting such a line with the reader's own
`TimeZoneInfo.Local` silently assumes the writing and reading processes agree on the zone.
They do not: each caches its zone at process start and follows neither a later `tzutil` nor
a Windows auto-timezone change. The error is `Offset_writer − Offset_reader` — zero only
when both believe the same thing, right or wrong. Field measurement over 11,068 sessions
found 26 sessions with a real error, from +1 h to −17 h.
Two designs failed before this one:
* **Fix the reader's belief** (`TimeZoneInfo.ClearCachedData`, live OS offset): makes the
agent isolated-correct and thereby breaks the majority of sessions, where two equally
stale beliefs cancel.
* **One measured offset per file** (agent 2.0.1410, reverted in `04b1a7c6`): a single log
file holds lines from multiple writer *eras*. `AgentExecutor.log` is written by
short-lived child processes whose zone belief flips per process — interleaved in one
file, and flipping *back* (fixture:
`tests/fixtures/cmtrace-logs/agentexecutor-two-writer-eras-v1.cmtrace`). Any cross-line
anchor — per file, or nearest-in-write-order — applies one era's offset to the other
era's lines. In the field this shifted `script_started` events by −9 h against their
correct completions.
# Schema
The resolution rule, in order:
1. **Writer-declared bias** (`+480` suffix): authoritative, used as-is. Never a calibration
anchor — it needs no measurement and must not overwrite one.
2. **Per-line self-anchoring** — only for lines read in a *provably fresh* pass:
```
offset_line = round((local − agentUtcNow) / 15 min) · 15 min
lineUtc = local − offset_line
```
Every real UTC offset is a whole multiple of 15 minutes, so the rounding absorbs poll
and flush latency. No cross-line state exists, so interleaved eras resolve line by
line — the property the fixture test pins. The line's own timestamp still contributes
sub-poll precision and ordering (two lines 6 ms apart stay 6 ms apart; a plain
`occurredAt = now` could not do that). Guards: grid residual ≤ 2 min, offset within
UTC−12 … UTC+14.
3. **Uniform reader-zone fallback** for everything else: wrong in absolute terms whenever
the writer held a different belief, but *self-consistent* — both ends of a duration are
wrong by the same amount, so derived durations stay right. The revert's core lesson:
partially corrected is strictly worse than uniformly wrong.
**Freshness** is what makes rule 2 sound, and it is strict:
* Every poll look at a file stamps `LastCheckedUtc` (`LogFilePositionTracker.MarkChecked`),
including "no new data" and an empty first sight — so a file first seen empty
(`AgentExecutor.log` appearing mid-enrollment) anchors from its very first content.
* A pass's lines are fresh only if the file was previously observed *in this process* and
the gap since the last look is ≤ `FreshLineMaxAge` (30 s — far above the 100 ms poll,
far below the 13-minute zone where a line's *age* could round onto the offset grid).
* A restart-restored position bookmark deliberately carries **no** freshness: the first
pass after a restart reads downtime backlog and must never anchor.
**Provenance** (`sourceOffsetOrigin` in event DataJson): `bias` | `line-anchored` |
`reader-zone-fallback` (plus retired `calibrated` from 2.0.1410 events). The applied
offset is always recorded (`sourceOffsetMinutes`), the raw local time too
(`sourceLocalTs`), so any event can be re-derived later. The per-*file* measurement still
runs, purely observationally (`measuredWriterOffsetMinutes`, Info log when it disagrees
with the process zone).
# Constraints
* An anchored line resolves to `now ± 2 min` *by construction*. The accepted edge: a
freshly written line carrying a **replayed** old timestamp whose age is exactly
N×15 min ± 2 min anchors to ≈now instead of its replayed past — bounded error, and
sub-24 h replays are treated as current by the historic-replay guard anyway.
* Snapshot readers (`StallProbeCollector`, `LogParserCollector`) cannot tail and therefore
cannot prove freshness — they stay on the marked fallback. This is a documented limit,
not an omission; sharing the per-file measurement with them would re-apply exactly what
the revert removed.
* Mixed pairs remain possible across a restart (start resolved from backlog fallback,
completion line-anchored) and are recognizable via provenance.
# Backend regression tripwire
The backend watches for the failure mode this design eliminates. On every terminal ingest
batch, the counter reconcile's existing event-partition scan additionally collects
`Δ = ReceivedAt − OccurredUtc` per event, split by `Source == "ImeLogTracker"` versus the
rest (`CmTraceSkewTripwire`, hooked in `EventIngestProcessor`). If
`median(Δ_IME) − median(Δ_other)` is a clean 15-minute multiple (residual < 2 min — the
same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct
upload batches per side, and ≥ 80 % of the individual IME deltas (relative to the other
side's median) themselves sit within 2 min of some grid multiple — added after the 2026-08-28
soak, when relaunched agents re-tailing the IME log produced one burst holding most of a
session's IME samples with a continuum of ages whose median hit the grid by chance (7 false
fires in a week, 0 true) — a `CmTraceTimeSkewRegression` ops event fires (category Agent,
warning), carrying all numbers plus the session's `sourceOffsetOrigin` histogram.
Bias-dominated sessions are suppressed (writer-declared offsets cannot be an anchoring
regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
flip-backs). Kill switch: app setting `CmTraceSkewTripwireDisabled`. Goal state: the event
never fires — any hit is an anchoring case the per-line design misses, or a detector bug.
Device-clock problems (the customer-actionable cousin) are covered separately by the
`clock_skew` analyze condition behind `ANALYZE-DEV-008`, which excludes IME-derived events
precisely so an anchoring regression can never surface as a customer finding.
# Citations
* `CmTraceOffsetCalibrator.TryMeasureOffset` — the pure grid measurement, shared by
anchoring and the observational per-file path.
* `CmTraceSkewTripwire` + `CmTraceSkewTripwireTests` — the backend tripwire above.
* `RuleEngine.ConditionEvaluators.EvaluateClockSkewCondition` + `RuleEngineClockSkewTests`
— the customer-facing device-clock metrics (batch-median frames, spool-spread filter,
plateau step detection, end-state persistence).
* `ImeLogTracker.LogProcessing.cs` `ResolveEntryUtc` — the resolution order above.
* `ImeLogTrackerLineAnchoringTests` — resolves-to-T across writer beliefs, interleaved
eras in one chunk, freshness boundaries (first sight, empty first sight, poll gap,
restart restore), the grid-replay edge, and the field fixture replayed at production
poll cadence.
* `CmTraceCalibratorFileIsolationTests` — pins per-file isolation of the observational
measurement (session e9753578 logged a cross-file pairing the committed code could not
be shown to produce; the mechanism remains open, the resolution path is immune by
design, and `CalibrateFrom` logs the anchor timestamp as a tripwire).
* `tasks/todo.md` (2026-08-20) — full evidence trail: field measurements, the revert,
the two-writer-era fixture, and the disproven foreign-binary hypothesis (the SHA
mismatch was the known release race against the 5-minute AdminConfiguration cache).
-114
View File
@@ -1,114 +0,0 @@
---
type: Concept
title: Continue-Anyway Observation Mode — Device-Phase ESP Failure Defang
description: Operator-set tenant opt-in that turns an immediate Device-phase ESP terminal failure on a Continue-Anyway-enabled profile into a bounded 60-minute observation; the DAD-validated real-user desktop plus the Hello gate detect the true end, and the session completes as Succeeded with an esp-soft-failure marker.
resource: /src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
tags:
- agent
- decision-engine
- esp
- continue-anyway
- soft-failure
- tenant-config
timestamp: 2026-08-08T00:00:00+02:00
---
# Continue-Anyway Observation Mode
## Problem
A Classic user-driven enrollment whose Device ESP hits the configured terminal timeout
(`syncFailureTimeoutMinutes`, typically 30) fails **before AccountSetup is ever entered**.
The classic advisory defang (`HandleEspTerminalFailureV1`) requires
`AccountSetupEnteredUtc != null`, so these sessions hard-fail immediately — even when the
ESP profile allows "Continue anyway" and users demonstrably dismiss the failure screen and
reach a working desktop. A fleet held at the timeout wall by one slow blocking app then
shows ~100% Failed (tenant c9787ba2: 948/1000 `esp_terminal_failure`, culprit "Encompass
Hybrid Installer"), which tells the tenant nothing.
## Mechanism
Opt-in flow (default off — absence keeps the immediate hard fail):
1. **Tenant setting** `EnableEspContinueAnywayObservation` (`TenantConfiguration`,
operator-set only, NOT exposed in the tenant admin settings UI; the toggle lives in
the Global-Admin tenant management modal next to Bootstrap Token / Unrestricted Mode,
and the field is GA-gated on both write paths — the generic PUT silently reverts it
for tenant admins, the field patch rejects it with 400) → served via `agent/config`
(`AgentConfigResponse`, ConfigVersion 37).
2. **Agent** stamps `espContinueAnywayObservationEnabled=true` into the
`EspConfigDetected` signal payload (both emitters: the orchestrator FirstSync
bootstrap and `DeviceInfoCollector.PostEspConfigDetectedSignal`) — only when enabled,
so the kernel stays config-free and replay/recovery reconstruct the fact from the
signal log.
3. **Engine** (`HandleEspTerminalFailureV1`): a terminal ESP failure is defanged into an
`esp_failure_advisory` (`advisoryReason=esp_failure_defanged_continueanyway_observation`)
when ALL hold: profile allows Continue-Anyway, observation opt-in observed,
`AccountSetupEnteredUtc == null`, and the scenario is Classic/Unknown with
`PreProvisioningSide=None` and no registry self-deploying marker (WhiteGlove /
SelfDeploying / DevicePreparation have no interactive user to press the button).
4. **Observation window**: the shared `AdvisoryCompletion` deadline is armed with a
**60-minute** window (classic advisory: 30) — long enough for failure screen →
Continue anyway → sign-in → desktop, or a full device-phase re-run via "Try again".
## Detecting the real end
The active-advisory-without-AccountSetup shape is unique to this arming site, so
`IsContinueAnywayObservationActive(state)` (advisory recorded ∧ not resolved ∧
AccountSetup never entered) needs no separate opt-in re-check. Three outcomes:
* **Continue anyway** — the DAD-validated real-user desktop arrives (defaultuser0/SYSTEM
excluded at the detector) and the Hello gate is satisfied (resolved, or policy
explicitly disabled without an observed wizard): `HandleDesktopArrivedV1` completes
eagerly through Finalizing (trigger `DesktopArrived:ContinueAnywayObservation`) — the
session end reflects the real end, not the window edge. The IME user-session gate is
deliberately waived: it anchors on `AccountSetupEnteredUtc`, which is structurally
absent here. The terminal `enrollment_complete` carries `espSoftFailure=true`,
`completionSource=continue_anyway_observation` and the failed category; the backend
stamps `EspSoftFailure`/`CompletionSource` on the Sessions row (+ SessionsIndex
mirror) and the web renders the amber "with issues" badge plus a detail banner.
Sessions completing under the CLASSIC advisory (AccountSetup entered) carry the same
marker with `completionSource=continue_anyway_post_accountsetup`.
* **Try again** — the ESP re-runs the failed category; `DeviceSetupProvisioningComplete`
resolves the advisory via the 4910a5a5 recovery hook (`esp_failure_advisory_resolved`)
and the session continues on the normal rails, completing clean (no marker).
* **Nothing** — the window expires: un-defang to `enrollment_failed` with the original
`esp_terminal_failure` reason and
`advisoryReason=continue_anyway_observation_window_expired_without_completion_evidence`;
`LastFailureTrigger=EspTerminalFailure` keeps the likely-stuck app promotion on. Every
observed session therefore still gets a terminal verdict ≤ ~60 min after the failure.
Deadline-fire edge cases mirror the classic paths: desktop + unknown Hello promotes to
`AwaitingHello` with the HelloSafety window (never-observed promote), and enforcement
progress since arming re-arms the window (30-min re-arm, convergent) instead of failing a
device mid-"Try again".
## Status semantics
No new terminal status: the session is `Succeeded` + `EspSoftFailure=true` (user
decision 2026-08-08). Success-rate conventions stay untouched; honesty lives in the
badge, the detail banner, and ANALYZE-ESP-004 (which continues to fire on the un-defang
expiry path via `mayHaveContinuedAnyway`).
# Examples
Session `53d1e9f6-4eef-4f3b-a999-c63e96c5148d` (tenant c9787ba2, 2026-08-07) is the
motivating shape: DeviceSetup/Certificates failed at the 30-min wall while "Encompass
Hybrid Installer" was still installing, AccountSetup 0/5, Continue-Anyway allowed —
hard-failed under the default semantics.
Tests: `ContinueAnywayObservationTests` (DecisionCore.Tests) — defang, 60-min arming,
opt-out/scenario regressions, eager desktop completion with marker, clean-completion
marker absence, expiry un-defang, hello promotes, category recovery.
# Citations
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — arming site,
observation branches in `HandleAdvisoryCompletionDeadlineFired`,
`IsContinueAnywayObservationActive`.
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` —
`HandleDesktopArrivedV1` observation completion, `BuildEnrollmentCompleteEffect`
soft-failure marker.
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs`
/ `TableStorageService.Sessions.cs` — session stamping + index mirror.
* `src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx` — amber badge.
-228
View File
@@ -1,228 +0,0 @@
---
type: Reference
title: V2 Agent — Decision Engine (DecisionCore)
description: The pure-reducer state machine that decides enrollment completion/failure — signals, state, effects, the A/B/C completion arms, and its invariants.
resource: /src/Shared/AutopilotMonitor.DecisionCore
tags:
- agent
- decision-engine
- reducer
- completion
timestamp: 2026-07-11T00:00:00+02:00
---
# V2 Agent — Decision Engine (DecisionCore)
DecisionCore (`src/Shared/AutopilotMonitor.DecisionCore`, netstandard2.0) is a **pure
reducer**: a deterministic state machine with no I/O, no clock reads, no threads. The
agent feeds it an ordered stream of *signals*; it returns a new immutable state plus a
list of *effects* the host must execute. Because it is pure, the same persisted signal
stream always reproduces the same journal and terminal outcome — that is what makes
crash recovery a replay instead of a guess (determinism guarantee "L.5",
`Engine/IDecisionEngine.cs`).
# The reducer contract
```
DecisionStep step = engine.Reduce(DecisionState oldState, DecisionSignal signal)
// DecisionStep = { NewState, Transition, Effects[] }
```
- `Reduce` never throws: a handler exception advances only bookkeeping and journals a
`reducer_exception` dead-end (fail-safe L.16).
- Dispatch is a switch on `(signal.Kind, signal.KindSchemaVersion)`; unknown pairs
become journaled dead-ends, never crashes.
- Every step yields a `DecisionTransition` journal record: `Taken` (state change vs
dead-end + reason), `StepIndex`, `FromStage/ToStage`, `Trigger`, `Guards`,
`ReducerVersion`.
# State
`DecisionState` (schema **v4**) is an immutable value object; "change" = builder copy.
Key content:
- Identity + progress: `SessionId`, `TenantId`, `Stage`, `Outcome`, `StepIndex`,
`LastAppliedSignalOrdinal`, `AgentBootUtc` (replay-safety anchor).
- **Facts** as `SignalFact<T>` — value **plus** `SourceSignalOrdinal` provenance, e.g.
`AccountSetupEnteredUtc`, `EspFinalExitUtc`, `DesktopArrivedUtc`, `HelloResolvedUtc`,
`ImeUserSessionCompletedUtc`, `HelloPolicyEnabled`, `HelloOutcome`.
- Aggregates: `AppInstallFacts`, `ScenarioProfile` + `ScenarioObservations`
(enrollment-scenario classification, monotonic), `ClassifierOutcomes`, `RealmJoinFacts`.
- `Deadlines` — active timers the host has been asked to arm.
Stages: `SessionStarted → EspDeviceSetup → EspAccountSetup → AwaitingHello →
AwaitingDesktop → Finalizing → Completed` (plus `Failed`, `WhiteGloveCandidate/Sealed`,
`AwaitingDeviceOnlyEsp` for self-deploying, and edge stages). Terminal =
`Completed | Failed | WhiteGloveSealed`. `Finalizing` is deliberately non-terminal
(grace window). Outcomes: `EnrollmentComplete`, `EnrollmentFailed`,
`WhiteGlovePart1Sealed`, `Aborted`, `AdminPreempted`.
# Signals
`DecisionSignal` is the only input. Fields: `Kind` + `KindSchemaVersion` (dispatch key),
`SessionSignalOrdinal` (monotonic, assigned **only** by the ingress worker — never by
collectors), `OccurredAtUtc`, mandatory `Evidence` (classified `Raw | Derived |
Synthetic` — a signal without evidence is a bug), `Payload` (string dict) and
`TypedPayload` (structured sidecar).
Signal kinds by family (`Signals/DecisionSignalKind.cs`):
- **Raw observations**: `EspPhaseChanged`, `EspExiting`, `EspResumed`,
`EspTerminalFailure`, `DesktopArrived`, `HelloResolved`, `ImeUserSessionCompleted`,
`DeviceSetupProvisioningComplete`, `AccountSetupProvisioningComplete`,
`AppInstallCompleted/Failed`, `AadUserJoinedLate`, `SystemRebootObserved`,
`EspConfigDetected`, `HelloPolicyDetected`, WhiteGlove + RealmJoin kinds, …
- **Synthetic**: `DeadlineFired` (scheduler timer), `ClassifierVerdictIssued`,
`EffectInfrastructureFailure`.
- **Lifecycle**: `SessionStarted`, `SessionAborted`, `AdminPreemptionDetected`.
- **`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) |
| `EmitEventTimelineEntry` | timeline events incl. terminal `enrollment_complete` / `enrollment_failed` | transient — retried 100/400/1600 ms |
| `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`,
`sessionStage`, `stepIndex`, `signalsSeen`, `signalEvidence`, `signalTimestamps`,
`scenario` — so every completion/failure in the portal is explainable from its own
event payload.
# Completion logic
## The A/B/C arms
The arms are **not** three pipelines — they are three alternative sufficient conditions
inside one guard, `ShouldTransitionToAwaitingHello`
(`Engine/DecisionEngine.Shared.cs`), which gates promotion out of the ESP stages toward
Hello/desktop completion:
- **Arm A — registry-strong AccountSetup success**: `AccountSetupProvisioningSucceededUtc`
is set (the ESP registry reported `categorySucceeded`). Merely *entering* AccountSetup
is insufficient — Shell-Core 62407 fires at every ESP page transition.
- **Arm B — SkipUser flow**: `ScenarioObservations.SkipUserEsp == true` — with no
User-ESP page, the first `esp_exiting` *is* the genuine final exit.
- **Arm C — 4-fact eager completion** (all four mandatory):
1. AccountSetup was entered, and
2. a post-AccountSetup ESP final exit occurred (ordinal-ordered), and
3. a *genuine* IME user-session completion (at-or-after the AccountSetup anchor —
guards against the `defaultuser0` ghost), and
4. a DAD-validated real-user desktop arrival.
Arm C applies eagerly the same conjunction the 30-minute `AdvisoryCompletion`
backstop would eventually trust.
**Layering note:** Arm C's app-install hardening lives in the **adapter**, not the
reducer. `ImeLogTrackerAdapter` defers posting `ImeUserSessionCompleted` while required
user-ESP Install-intent apps are still pending (IME logs "Completed user session" per
*pass*, not once per session — sessions 14690fc2/6cb01530). The reducer stays free of
app-pending guards; adapters sanitize evidence before it becomes a signal.
**Zero user-targeted apps** (sessions 81daa77f/75d6ae8e): IME never writes its
"Completed user session" line at all when the user has no assigned apps — its zero-app
branch returns first — so fact 3 was unreachable by construction and every such device
fell through to the backstop. `IME-USER-SESSION-ZERO-APPS` reads the line IME *does*
write there and posts the same signal, guarded to the AccountSetup phase. The event
carries an `evidence` field (`zero_user_apps_enumerated` vs `user_session_completed`)
naming which observation carried the completion. See
[zero-user-apps-completion-gate.md](zero-user-apps-completion-gate.md).
## Completion attempt sites and sequence
Completion can be attempted from whichever fact arrives last: `HelloResolved`,
`DesktopArrived` (including the Hello-disabled fast-path when `HelloPolicyEnabled ==
false` and an arm holds), `EspExiting`, `ImeUserSessionCompleted`,
`AccountSetupProvisioningComplete`, the Hello safety deadline, or the 30-minute
advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
1. **Completion gates** (currently only the RealmJoin AND-gate): if RealmJoin was
detected and has no terminal outcome yet, completion is *deferred* — the session
stays in stage and emits `completion_waiting`. Three terminals open the gate:
`Resolved` (DeploymentPhase 110), `FirstDeploymentIncomplete` (phase left the
first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted,
typically by an interactive logon reclassifying the run as secondary-user; emits
the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and
`Timeout` (60-min deadline from detection). The abort rule reads the *persisted*
`RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged`
signal), so it survives agent restarts — the `RealmJoinDetected` replay path
evaluates it too. A first observation of 210 does **not** release (RJ may have
completed before agent boot and deploy afterwards).
**Activity-based timeout extension** (report 55e6afd61c9d — a large RJ package
catalog outlived the 60-min window mid-install): when the timeout deadline fires
while the first deployment is demonstrably active — `LastDeploymentPhase` 100/101
*and* deployment activity (`RealmJoinPhaseChanged` or a package observation, tracked
in `RealmJoinFacts.LastActivityUtc`; the phase captured at detection deliberately
does not count) within the last 60 min — the handler re-arms the deadline to
`lastActivity + 60 min` instead of timing out, emitting an Info
`realmjoin_timeout_extended` event. Re-arms clamp to an absolute cap of
**detection + 4 h**. Package activity is only observable at package *completion*
(RJ writes the registry subkey when the install finishes), so a single install
that is registry-silent for over 60 min still times out — the eventual
`realmjoin_timeout` carries a `reason` payload (`hard_timeout` / `inactivity` /
`absolute_cap`) telling the three shapes apart. A stale fire of an older deadline
incarnation (armed due later than the fire time; `OccurredAtUtc = DueAtUtc` per
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
The idle case (detected, no deployment activity ever) times out at 60 min
unchanged, and phase 200/210 activity never extends.
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
carries a bounded resolution of its own. Before this, the shorter timer always won —
session 75d6ae8e was failed 2 min after its 25th successful RealmJoin package.
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
timeline event with full audit payload.
**Self-deploying/kiosk** is a separate rail: no Hello, no user desktop —
`AwaitingDeviceOnlyEsp` with a detection deadline as the sole terminal entry, protected
by stale-fire and race guards. A registry-confirmed self-deploying profile also waives
IME false-positive AccountSetup entries.
# Invariants (violating any of these is a bug)
- **Post-terminal dispatch guard**: after `Completed`/`Failed`, every signal is a
bookkept dead-end — **except `InformationalEvent`** (single-rail pass-through must
keep flowing). `WhiteGloveSealed` is unguarded on purpose (Part 2 resumes fresh).
- **`InformationalEvent` never mutates decision state** — exactly one pass-through
timeline effect, payload copied 1:1.
- **`AadUserJoinedLate` is observation-only** — never completion evidence.
- **Hello policy is orthogonal to completion** — `HelloPolicyEnabled` tunes wait
cadence, never gates the outcome.
- **Raw IME "Completed user session" is weak evidence** — can run under `defaultuser0`;
only meaningful with a real-user desktop and the AccountSetup anchor (Arm C).
- **Deadline bases are floored at `AgentBootUtc`** — replayed backdated signals must
not arm past-due timers at boot.
- **Reducer-side deadline cancels need an explicit `CancelDeadline` effect** — a
builder-only removal leaves the OS timer live.
- **No silent parking**: a non-terminal post-AccountSetup session must always hold a
resolution-capable deadline; a 10-min dwell tripwire emits
`session_parked_without_deadline`.
# Hosting & threading (inside the agent)
Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/`):
- `SignalIngress` — bounded queue (256, back-pressure) + **one** worker thread. Per
signal, in order: assign ordinals → `SignalLog.Append` (durable, *before* reducing;
append failure ⇒ reducer does not run, no ordinal gap) → `engine.Reduce` →
`DecisionStepProcessor.ApplyStep`.
- `DecisionStepProcessor.ApplyStep` (same thread, no locks): journal append (the only
hard-throwing path; 3 consecutive failures escalate to quarantine) → effects run
synchronously via `EffectRunner` → best-effort snapshot → advance `CurrentState` →
one-shot terminal-stage hook (raises the agent's `Terminated` event off-thread).
- The `DeadlineScheduler`'s `Fired` event loops back in as synthetic `DeadlineFired`
signals.
- On restart, `RecoveryCoordinator` folds the persisted signal log through a transient
`new DecisionEngine()` before the live pipeline starts — see
[logs & persistence](logs-and-persistence.md).
-147
View File
@@ -1,147 +0,0 @@
---
type: concept
title: Diagnostics Package
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
resource: agent
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
timestamp: 2026-08-23
---
# Schema
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
## Archive layout
```
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
<built-in sections> catalog order, see below
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
package-manifest.txt every packaging decision (always written)
_TRUNCATED.txt only when a cap skipped something
```
## The built-in catalog is the single source of truth
Everything the package collects **before** any configured path is data, not code paths:
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
The agent iterates it in order; the backend serves the same list to the portal
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
agent does. There is no second copy to drift.
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
| --- | --- | --- | --- | --- | --- |
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
name. Two sections may share a ZIP folder only when they read different source folders
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
### Conditions
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
effective configuration and the enrollment scenario; unknown values fail closed.
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
No ConfigVersion bump — no new wire field.
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
fallback rules count as Classic. The probe is a registry read, evaluated once per build
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
treated as Classic and logged — the evtx export is skipped, never the package. The channel
is exclusively locked while active, so the existing `wevtutil epl` export path
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
interactive user exists — identical to configured paths). Active ones log
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
`ADDED / NO MATCH / FOLDER MISSING` lines.
## Built-ins bypass the configured-path guards — on purpose
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
exactly like the five sections that never went through the guard before — and
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
## Configured paths: global ∪ tenant
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
same-named folder share one ZIP folder.
## Portal surfaces
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
neutral condition pills) above the editable global list; add-row first.
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
read-only, then the tenant's own entries; every role with a tenant membership can read the
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
full values in tooltips.
## Test seams
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
because Shared has no test project.
# Examples
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
ImeBootstrapperEventLog`. A tenant that had added
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
entry is removed.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
-113
View File
@@ -1,113 +0,0 @@
---
type: Concept
title: DO GroupId Auto-Set — Delivery Optimization peering group from a network fingerprint
description: EnableDoGroupIdAutoSet (ConfigVersion 40) makes the agent set the DOGroupId policy value to a deterministic GUID derived from the default gateway's IPv4 address and MAC (RealmJoin-compatible byte layout), so devices on the same local network form one DO peering group during enrollment; foreign DOGroupId/DOGroupIdSource policies always win.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs
tags:
- agent
- delivery-optimization
- network
- tenant-configuration
timestamp: 2026-08-21T00:00:00+02:00
---
# DO GroupId Auto-Set
## Schema
### The problem
With Delivery Optimization download mode Group (2), peering is scoped by GroupId. When no
GroupId is configured, Windows derives it from AD site → authenticated domain SID → Entra
tenant ID — for a cloud-native tenant that means **one tenant-wide group**, so "peers on
the same network" degrades to "peers anywhere in the tenant" and DO's cloud service has to
match them by public IP. A GroupId derived from the local network makes the group exactly
"devices behind the same gateway" — including branch offices behind NAT. RealmJoin ships
this as its network-fingerprint GroupId; our agent is on the device even earlier (during
ESP, when the big app downloads actually happen), so it can seed the same value for
Autopilot enrollments.
### The setting
`TenantConfiguration.EnableDoGroupIdAutoSet` (`bool?`, default null→false), served as
`AgentConfigResponse.EnableDoGroupIdAutoSet` — **ConfigVersion 40**. Portal: Settings →
Agent → Settings, toggle "Set Delivery Optimization Group ID", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report. `RemoteConfigMerger` maps the bool 1:1; it survives the offline
config cache like every plain feature flag.
The agent does NOT configure `DODownloadMode` — the fingerprint GroupId only has an
effect when the tenant already deploys download mode Group (2) via Intune/GPO.
### The fingerprint
`DoGroupIdService.ComputeGroupId(ipv4Bytes, macBytes)` builds a 16-byte GUID:
```
{101,48,6} + {0,0,0} + {ip[1], 0, ip[2], ip[3]} + mac[0..5] → new Guid(byte[16])
```
Gateway discovery reuses the shared `NetworkInterfaceLocator` heuristic (Up, not
Loopback/Tunnel, non-0.0.0.0 gateway; IPv4 gateway required), the gateway MAC comes from
`ArpNativeMethods` (`iphlpapi!SendARP`). The byte layout — including the IP shuffle and
the zero padding — is **RealmJoin-compatible and must never change**: devices managed by
RealmJoin on the same network compute the identical GUID and peer with ours. The registry
value is written as `Guid.ToString()` (braceless lowercase), identical to RealmJoin's
`SetValue(name, Guid)` string form. Deliberate divergence from RealmJoin: **no
random-GUID fallback** — a random per-device ID would isolate the device in a one-member
group, which is worse than the OS default grouping, so no gateway/no MAC means skip.
### Ownership + conflict rules (never fight a foreign policy)
Target: `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization\DOGroupId`
(64-bit registry view — the Policies store is WOW64-redirected; key paths reused from
`DoThrottlePolicyReader`). Only this GPO-store value is written; the legacy pre-1607
`GroupId` value name is intentionally not.
Skip conditions (Info event with `skipReason`, nothing written):
| skipReason | Meaning |
|---|---|
| `no_ipv4_gateway` | No active interface with an IPv4 default gateway |
| `arp_failed` | Gateway MAC could not be resolved via SendARP |
| `group_id_source_policy` | `DOGroupIdSource` configured in GPO or MDM store (dynamic group source wins) |
| `mdm_policy_present` | `DOGroupId` managed via the Policy CSP store (Intune owns it) |
| `foreign_value_present` | Existing GPO value neither matches the computed GUID nor the one we last wrote |
Ownership is tracked through the `StartupEventGate` fingerprint for the
`do_group_id_auto_set` key: `written:<guid>` records the GUID we last wrote, so after a
gateway change the agent recognizes its own stale value and updates it — while a value
written by anyone else is never overwritten (`DecideAction`: empty→write, equal→no-op,
ours→update, else→skip; unparseable existing values count as foreign). Every write is
verified by readback.
### The event
One `do_group_id_auto_set` event per outcome change (Source `StartupEnvironmentProbes`,
gated on the fingerprint — unchanged reruns across reboots stay silent; errors never
latch, so each failing start keeps its retry trail). Severity: Info for
success/skip, Warning for errors. Data: `groupId`, `previousValue`, `gatewayIp`,
`gatewayMac`, `written`, `skipped`, `skipReason`, `success`, `error`.
## Examples
First boot on a network with gateway `192.168.178.1` / MAC `00-11-22-33-44-55`:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId set to
00063065-0000-00a8-b201-001122334455 (gateway 192.168.178.1)
```
Tenant already deploys a DO GroupId via Intune:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId not set: mdm_policy_present
```
## Citations
* Service (fingerprint, ownership, write+readback): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs`
* ARP interop: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ArpNativeMethods.cs`
* Probe wiring + event + fingerprint dedup: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* DO policy stores (key paths, Registry64 rationale): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs`
-88
View File
@@ -1,88 +0,0 @@
---
type: Concept
title: Agent Endpoint Migration — Config-Channel Re-Home
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs
tags:
- agent
- backend
- config
- migration
- security
timestamp: 2026-07-18T00:00:00+02:00
---
# Problem
The agent's API base URL is compiled into the binary (`Constants.ApiBaseUrl`). No DNS/domain
indirection is possible in front of the API: mTLS client-cert auth does not survive a reverse
proxy (Front Door), and Flex Consumption custom-domain TLS is broken. A backend move (like the
2026-07-18 WEU→GWC subscription migration) therefore hard-cuts every deployed agent still
pointing at the old hostname. In-flight agents self-terminate safely (local MaxLifetime watchdog
+ CleanupService), but their sessions are lost and a per-tenant region move (EU→US) has no
mechanism at all.
# Mechanism
`AgentConfigResponse.MigrateToApiBaseUrl` — served on `GET /api/agent/config` (incl. the
bootstrap wrapper), the same control channel as the kill switch, fetched at every agent
process start.
Agent flow (`Program.RunAgent`, Phase 4):
1. Config fetched from the compiled-in (old) base URL.
2. Kill-switch check runs FIRST — **kill wins over migrate**.
3. `AgentRuntimeConfig.Resolve` detects the migration signal directly after the fetch and
returns early, BEFORE merge / binary-integrity / bootstrap-cleanup — none of those side
effects may run against a config from the backend being abandoned (a migration stub may
advertise stale agent hashes; a spurious `IntegrityCheckFailed` + force-update would be
harmful).
4. `Program.RunAgent` swaps `AgentConfiguration.ApiBaseUrl`, disposes the old client stack,
rebuilds Phase 3 (auth clients) and re-runs Phase 4 against the new URL —
`allowEndpointMigration: false`, so the new backend's config **cannot chain a second hop**.
5. Kill-switch check runs again on the NEW backend's config; startup continues normally
(Phase 5+ builds mTLS/telemetry clients from the updated base URL).
# Hardening (mirrors the kill switch)
* **Live fetch only** — the on-disk config cache strips `MigrateToApiBaseUrl` on write AND
read (`RemoteConfigService.CacheConfig` / `LoadCachedConfig`); `EndpointMigration.ResolveTarget`
re-checks `LastFetchOutcome == Succeeded` as defence-in-depth. A planted cache file can
never re-home an agent.
* **Allowlist validated on BOTH sides** — `AgentEndpointMigrationRules` (Shared, single
source): https only, host must end in `.azurewebsites.net` on a label boundary, no
path/query/userinfo/port. The backend refuses to serve an invalid admin value
(`AgentMigrateRejected` warning); the agent refuses to honour one even if the backend was
compromised.
* **One hop, no loops** — second Resolve pass never migrates; a target equal to the current
base URL is a no-op (steady state during the migration window).
# Operation
Global admin settings (`AdminConfiguration`, admin portal → Global Settings):
* `AgentMigrateApiBaseUrl` — global target, set on the backend being **abandoned**; clear it
after the migration window.
* `AgentMigrateTenantOverridesJson` — JSON object `tenantId → URL` for per-tenant moves.
An entry with an **empty string** pins that tenant against the global target (staged rollout).
Overrides win over the global value.
Delivery evidence: every served target logs `AgentMigrateServed` (Warning — reaches App
Insights) with tenant, serial, agent version and target. Agent-side, the re-home is logged as
`Endpoint migration: backend re-homed this agent from … to …`.
Reach limitation: agents that cannot complete an authenticated config fetch (e.g. cert-broken
devices) never see the signal — they fall back to the local MaxLifetime/emergency-break
lifecycle. During a migration window the old backend must keep running as a serving stub;
keeping the old `*.azurewebsites.net` name parked afterwards also prevents hostname
re-registration by third parties while stragglers drain.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs` — shared validation rules
* `src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs` — wire field
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `ResolveMigrateTarget` + serving (ConfigVersion 33)
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs` — agent-side evaluation
* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs` — early return before merge/integrity
* `src/Agent/AutopilotMonitor.Agent.V2/Program.cs` — one-hop rebuild in `RunAgent`
* Tests: `AgentEndpointMigrationTests` (backend), `EndpointMigrationTests` + `RemoteConfigServiceCacheHardeningTests` (agent)
-151
View File
@@ -1,151 +0,0 @@
---
type: Concept
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspPolicyProviderProbe.cs
tags:
- agent
- esp
- enrollment-status-tracking
- co-management
- stall-detection
timestamp: 2026-07-24T00:00:00+02:00
---
# ESP Policy-Provider Stall Detection
## Schema
The Enrollment Status Page decides when its "Apps" category may progress from
"Identifying" using the **EnrollmentStatusTracking CSP**
(<https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>),
mirrored in the registry under
`HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\{Device|<UserSID>}\…`.
Two contracts matter:
1. **`Setup/Apps/PolicyProviders/<Name>`** — per the CSP doc: *"Existence of this node
indicates to the ESP that it shouldn't show the tracking status message until the
TrackingPoliciesCreated node has been set to true."* This wait has **no timeout**. A
registered provider that never sets `TrackingPoliciesCreated=1` parks the ESP at
"Apps (Identifying)" indefinitely — observed for days in the field. Exists in device
scope (`Device`) and user scope (`S-<SID>`, appears at sign-in).
2. **`DevicePreparation/PolicyProviders/<Name>/InstallationState`** — must reach 2
(NotRequired) or 3 (Completed); 1=NotInstalled, 4=Error. Device scope only. The ESP
applies a default 15-minute timeout here, so these stalls self-surface as ESP errors —
the detector still records them for the diagnostic trail.
Known provider names: **`Sidecar` is the Intune Management Extension** (the CSP doc names
"Intune's agents, such as SideCar"); **`ConfigMgr`** is the Configuration Manager client,
registered during Autopilot-into-co-management — the troubleshooting section of
<https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment> queries
exactly these registry paths. The same document states that ESP policy providers **are not
aware of each other** and that **pre-provisioning is unsupported** with Autopilot into
co-management.
## The field case (issue #106)
A tenant pre-provisions devices and installs the ConfigMgr client as a blocking app.
Occasionally the user-phase ESP reaches "Apps (Identifying)" and never leaves it — for
hours to days. On those devices `Device\Setup\Apps\PolicyProviders` contains **only a
`ConfigMgr` key (no `Sidecar`)** — and, per the customer's follow-up, **`TrackingPoliciesCreated=1`
IS set under it**. The provider is "complete" by the CSP value contract, yet the ESP
still waits. Manually renaming the key to `Sidecar` — which creates no tracking entries,
only satisfies the name — unblocks the ESP instantly.
The naive CSP-doc reading ("any provider with TrackingPoliciesCreated=1 satisfies the
wait") is therefore wrong for this case. What the evidence supports instead: provider
nodes are registered **by the Intune service at enrollment**, and its ESP-policy WAP
payload always registers `Sidecar` (ConfigMgr joins only via a co-management settings
policy — see the payload documented by Niehaus). The ESP waits on the provider it
expects **by name**; a foreign provider occupying the slot does not satisfy it,
regardless of its completion values. On the affected devices the CM client (installed
as a plain Win32 app, no co-management settings policy) evidently displaced the
Sidecar node — consistent with Microsoft's warning that providers "aren't currently
aware of others" and that pre-provisioning is unsupported with co-management. The exact
CloudExperienceHost mechanism is not publicly documented; the registry signature —
setupApps providers present, `Sidecar` absent — is deterministic either way.
## Detection
`EspPolicyProviderProbe` (fail-soft, static, `ScopedOverride` test seam — structural clone
of `EspTrackingInfoProbe`) enumerates all providers in both kinds and scopes.
`EspPolicyProviderStallDetector` evaluates a pure transition function on a 60-second tick
from the always-on `EspPolicyProviderStallHost` — **two arms**, same 15-minute wall-clock
dwell, one shared event type discriminated by `reason`:
* **Arm 1, `reason=provider_incomplete`** — a provider **continuously incomplete for
≥ 15 minutes**. The dwell clock starts at first incomplete observation and resets on
completion or disappearance. Missing values parse as incomplete
(fail-toward-observation). Catches e.g. Sidecar itself never reporting.
* **Arm 2, `reason=sidecar_provider_missing`** — **≥ 1 `Setup\Apps` provider registered
but none named `Sidecar`**, continuously for ≥ 15 minutes. Deliberately ignores
provider completeness (the field case had `TrackingPoliciesCreated=1`). Sidecar
registering later — the legitimate startup ordering — clears the condition and resets
the clock. DevicePreparation-only snapshots don't activate it.
* When both arms cross in the same round, ONE event is emitted and
`sidecar_provider_missing` wins as `reason` (it explains the hang regardless of the
foreign provider's state).
* Missing root key / empty provider list = normal early enrollment: clears all dwell
state (both arms), never fires.
* One `esp_policy_provider_stalled` **Warning** per newly stalled condition set
(`ImmediateUpload`), one-shot per condition in-process, cross-restart deduped via a
`StartupEventGate` fingerprint over the sorted currently-active fired conditions
(provider keys + a `sidecarMissing` sentinel) — a restart into the same stall stays
silent, a new condition joining re-reports.
* Payload: `reason`, `providers[]` (full table: name, scope, kind, raw values, complete
flag), `stalledProviders[]` with `stalledForMinutes`, `sidecarMissingForMinutes`
(arm 2 only), `sidecarRegistered`, `dwellMinutes`.
* **Purely observational**: emitted via `InformationalEventPost` (dispatch-guard exempt
pass-through) — never touches decision state. No config toggle, no ConfigVersion bump
(precedent: `disk_space_low`).
Deliberately **not** built on `StallProbeCollector` (its idle clock resets on any session
activity — a provider stall coexists with a chatty session) and not on the periodic
collectors (idle-stopped after 15 minutes — exactly the dormant window the dwell must
observe).
## Guardrail companion
`rules/guardrails.json` allow-lists `SOFTWARE\Microsoft\Windows\Autopilot` (category
"Autopilot / OOBE / Setup") so tenants can build their own gather rules on the subtree —
e.g. a `not_exists` registry rule on the `Sidecar` provider key. The subtree carries
deployment state only (provider registrations, tracking lists, app IDs, per-user SIDs);
sensitivity is at or below existing entries. Enforcement is agent-side (embedded
resource), so both the detector and the guardrail change ship with the agent release.
## Examples
Customer-case event (abbreviated) — note the provider is "complete" by CSP values, the
hang is carried entirely by `reason=sidecar_provider_missing`:
```json
{
"eventType": "esp_policy_provider_stalled",
"severity": "Warning",
"data": {
"reason": "sidecar_provider_missing",
"dwellMinutes": 15,
"sidecarRegistered": false,
"sidecarMissingForMinutes": 15.0,
"providers": [
{ "name": "ConfigMgr", "scope": "device", "kind": "setupApps",
"trackingPoliciesCreated": 1, "installationState": null, "complete": true }
],
"stalledProviders": []
}
}
```
`reason=sidecar_provider_missing` is the co-management signature;
`reason=provider_incomplete` with `sidecarRegistered=true` covers a stalled Sidecar or
third-party provider — the generalized contract violation.
# Citations
* EnrollmentStatusTracking CSP — <https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>
* 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/>
* Agent code: `EspPolicyProviderProbe.cs`, `EspPolicyProviderStallDetector.cs`, `EspPolicyProviderStallHost.cs` (SystemSignals / Orchestration.Hosts)
* Related: [MDM Reboot Coalescing](mdm-reboot-coalescing.md), [Gather-Rule Guardrails](../rules/gather-rule-guardrails.md)
-126
View File
@@ -1,126 +0,0 @@
---
type: Concept
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs
tags:
- agent
- gather-rules
- diagnostics
- tenant-configuration
timestamp: 2026-07-28T00:00:00+02:00
---
# Gather Rule Debug Log
## Schema
### The problem
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` |
| 7 | `emit_mode=on_change` — result hash unchanged, emit suppressed | `ShouldEmitOnChange` |
| 8 | Guard blocked the target (emits `security_warning`, but the rule yields no data) | `GatherRuleContext.EmitSecurityWarning` |
| 9 | logparser: no files matched, position tracker says nothing new, regex no-match, plain-text file parsed in default `cmtrace` mode, `maxLines` cap, wildcard capped at 20 files | `LogParserCollector` |
| 10 | Rule execution threw (agent log had only the message, no stack) | `ExecuteRule` catch |
### The setting
`TenantConfiguration.EnableGatherRuleDebugLog` (`bool?`, default null→false), served to the
agent as `AgentConfigResponse.EnableGatherRuleDebugLog` — **ConfigVersion 36**. Portal:
Settings → Agent → Settings, toggle "Gather Rule Debug Log", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report.
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
`%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log`; `false` → null, unless a
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
security-sensitive and deliberately **survives the offline config cache**.
### The trace file
`GatherRuleDebugLog` is an append-only writer held by `GatherRuleExecutor` and exposed
to collectors via `GatherRuleContext.DebugLog(ruleId, stage, message)` (null-safe no-op
when disabled — zero cost on the normal path). Line format:
```
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
```
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
(execution start), `collector` (null/empty results with likely cause), `guard`
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
position range, match count, parse failures, regex timeouts, mode; plus the
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
match** — line number, matched text, capture groups — capped at 10 per file per run),
`error` (full exception **with stack trace** — the agent log keeps only the message;
an invalid logparser regex is reported here including the offending pattern string).
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
Every write failure is swallowed — tracing must never break gathering. The trace never
leaves the device on its own — but because it lives in the agent log folder
(`Constants.LogDirectory`), the diagnostics package (`DiagnosticsPackageService`,
`*.log` pattern, `AgentLogs/` section) picks it up automatically, so a customer-initiated
diagnostics upload hands the trace to support without manual copying.
### Semantics
* **No hot-reload** — the flag is consumed once when `DefaultComponentFactory` builds
the `GatherRuleExecutorHost` at session start (per-enrollment lifecycle, same as
`EnableImeMatchLog`). Enable it in the tenant, then the *next* enrollment writes the
trace.
* **Zero-rules case is traced too** — flag on + backend delivered no gather rules →
a one-shot standalone `config` line, so the file is never just absent.
* **`--run-gather-rules` always traces** (operator diagnostic mode; CLI override or
default path) and mirrors every line to the console under `--console`.
## Examples
A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
```
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
... | - | phase | phase change: DeviceSetup -> AccountSetup
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
```
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
```
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
```
and a broken regex names itself:
```
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
```
## Citations
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
* Writer: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs`
* Config projection: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* Phase scoping / emit mode background: `../rules/gather-rule-phase-scoping.md`
-108
View File
@@ -1,108 +0,0 @@
---
type: Concept
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.
resource: /src/Shared/AutopilotMonitor.DecisionCore
tags:
- agent
- decision-engine
- hello
- completion
- whfb
timestamp: 2026-07-13T00:00:00+02:00
---
# Hello Wizard Un-Skip
Session `772fe502` (2026-07-13) completed as *Succeeded* while the user was inside the
Windows Hello wizard. Cause: the user-scoped WHfB CSP value
(`HKLM\SOFTWARE\Microsoft\Policies\PassportForWork\{tenant}\{scope}\Policies\UsePassportForWork`)
**flip-flops** during User-ESP while Intune policy sync is still writing. A single read
caught "disabled", the engine set `HelloPolicyEnabled=false` (set-once, poll stops), and
the arm-C completion synthesized `HelloOutcome="Skipped"` → 5 s `finalizing_grace` →
`enrollment_complete` — 230 ms *after* Shell-Core event 62404 (CXID `AADHello`) proved
the wizard had launched. The wizard-start was previously an `InformationalEvent` only
(timeline pass-through, no state mutation).
Two independent defense layers fix this:
# Layer 1 — `DecisionSignalKind.HelloWizardStarted` (engine)
Shell-Core 62404 with CXID `AADHello`/`NGC` is a **genuine wizard-lifecycle observation**
(unlike the unreliable `hello_provisioning_willlaunch` registry snapshot). It is now a
dedicated signal: `ShellCoreTracker.HelloWizardStarted` event → coordinator forward
(`EspAndHelloTracker`, timestamp mirror) → `EspAndHelloTrackerAdapter` (fire-once) →
reducer. The startup backfill also replays 62404 (5-min lookback), so an agent restart
mid-wizard does not lose the observation. The reducer records the set-once fact
`DecisionState.HelloWizardStartedUtc`.
**Prevention** — every "may policy-disabled stand in for a Hello resolution?" decision
routes through one predicate (`DecisionEngine.Completion.cs`):
```
HelloPolicyDisabledWithoutWizard = HelloPolicyEnabled == false && HelloWizardStartedUtc == null
HelloSatisfiedForCompletion = HelloResolvedUtc != null || HelloPolicyDisabledWithoutWizard
```
Once a wizard start is on record, the policy-disabled shortcut is vetoed at all five
Skipped-synthesis sites (EspExiting DeferredCompletion, DesktopArrived fast-path,
ImeUserSessionCompleted arm-C, AdvisoryCompletion deadline, AccountSetupProvisioningComplete
DeferredCompletion) *and* in the `completion_waiting` missing-prerequisites computation.
Sessions then take their pessimistic path: promote to `AwaitingHello` + arm HelloSafety
(300 s). The AdvisoryCompletion handler needed a dedicated
`HelloWizardObservedPromote` branch — its fallthrough is re-arm-or-**fail**, and
prevention alone would have failed a live-wizard session there.
**Cure** — `HandleHelloWizardStartedV1` (`DecisionEngine.Classic.cs`): when the state
already carries the engine-synthesized skip, the handler retracts it — cancel
`finalizing_grace` (if armed), null `HelloResolvedUtc`/`HelloOutcome`, back to
`AwaitingHello`, arm HelloSafety, emit `completion_waiting` (trigger
`HelloWizardStarted:UnSkip`). A real `HelloResolved` — or the HelloSafety timeout —
then decides the session.
Two deliberate design points:
1. **Synthetic-outcome discriminator.** The engine's synthesized outcomes are exact-case
`"Skipped"` / `"Timeout"` (constants `SyntheticHelloOutcomeSkipped`/`...Timeout`);
the tracker vocabulary is all-lowercase (`completed`, `skipped`, `not_configured`,
`timeout`, `wizard_not_started`). The cure only ever retracts the exact-case
`"Skipped"` — tracker-posted resolutions and the deliberate HelloSafety `"Timeout"`
are never touched.
2. **First fact-nulling reducer.** DecisionState facts are otherwise set-once/monotonic.
The retraction is legal exactly here because the fact is provably engine-synthesized
and provably wrong (the wizard it claimed would never appear is running). The cure is
**stage-agnostic** (guarded by the synthetic outcome, not by `Stage==Finalizing`):
a synthetic skip can also sit parked behind a closed RealmJoin completion gate, whose
release path completes on `HelloResolvedUtc != null` — the same bug through another
door.
# Layer 2 — confirmation second read (HelloTracker)
`HelloTracker.ApplyPolicyRead` (extracted state machine, `HelloTracker.cs`):
| Read | Effect |
|------|--------|
| `enabled` (first) | commit immediately (pessimistic direction; `confirmedReads=1`) |
| `disabled` (first) | mark pending only; 10 s poll timer stays alive |
| `disabled` while pending | commit disabled (`confirmedReads=2`) |
| `enabled` while pending | commit enabled + `flipFlopDetected=true` (the 772fe502 catch) |
| `null` while pending | clear pending, keep polling (a vanished value is flip-flop evidence) |
While pending, `_isPolicyConfigured` stays false, so the Hello wait timers use the
longer policy-unknown cadence — the safe direction. The confirmation audit fields ride
on the `hello_policy_detected` event `Data`.
# Residual risk
A wizard that starts only *after* `Completed` (agent already terminal + shut down) is
still not catchable — the dispatch guard dead-ends post-terminal signals by design. The
confirmation read shrinks the window for the stale-disabled commit; the wizard signal
closes it for every ordering where 62404 fires before the 5 s grace elapses.
# Citations
- Reducer handler + prevention predicate: `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` (`HandleHelloWizardStartedV1`), `.../DecisionEngine.Completion.cs` (`HelloSatisfiedForCompletion`), `.../DecisionEngine.Edge.cs` (`HelloWizardObservedPromote`)
- Agent signal path: `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs` (62404 + backfill), `EspAndHelloTracker.cs`, `SignalAdapters/EspAndHelloTrackerAdapter.cs`
- Confirmation read: `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs` (`ApplyPolicyRead`)
- Fixtures: `tests/fixtures/signal-kinds/hello-wizard-started-v1.json`, `tests/fixtures/enrollment-sessions/userdriven-hello-wizard-unskip-v1.jsonl`
- Related: [Decision Engine](decision-engine.md)
-17
View File
@@ -1,17 +0,0 @@
# V2 Agent
How the on-device agent works:
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Zero User-Targeted Apps](zero-user-apps-completion-gate.md) - why a user with no targeted Intune apps starved every user-phase completion path (IME never writes its "Completed user session" line in the zero-app branch), and why a completion gate that is still holding the session must outrank the 30-minute backstop.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
* [DO GroupId Auto-Set](do-group-id-auto-set.md) - the optional Delivery Optimization peering-group seed: deterministic GUID from default gateway IPv4 + MAC (RealmJoin-compatible), written to the DOGroupId policy value with readback, ownership-tracked so foreign Intune/GPO values are never overwritten; skip reasons and the do_group_id_auto_set event.
* [System Timeline Watcher](system-timeline-watcher.md) - clock steps (system_clock_changed: oldTime/newTime/timeDeltaMs/process, micro-slew floor) and completed sleep episodes (system_sleep_episode: sleep/hibernate/Modern Standby with authoritative enteredAt/exitedAt) from one System-channel EventLogWatcher with pre-agent backfill; feeds the web clock-set jump cause, the standby duration note, time-attribution SleepSpans and ANALYZE-DEV-012.
Reading order for newcomers: overview → decision engine → persistence.
-141
View File
@@ -1,141 +0,0 @@
---
type: Reference
title: V2 Agent — Logs, Persistence & Crash Recovery
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/RecoveryCoordinator.cs
tags:
- agent
- persistence
- signal-log
- recovery
- durability
timestamp: 2026-07-11T00:00:00+02:00
---
# V2 Agent — Logs, Persistence & Crash Recovery
An enrollment spans multiple reboots and can be killed at any moment (app-forced
restart, self-update, crash, power loss). The agent therefore persists three distinct
tiers of state — each answers a different question:
1. **Decision truth** (`State\`): *what did the decision engine see and decide?*
Signal log + journal + snapshot. Must be deterministic and replayable.
2. **Transport** (`Spool\`): *what telemetry still has to reach the backend?*
Durable upload queue + cursor. Best-effort; the backend dedups.
3. **Diagnostics** (`Logs\`, `Crashes\`): *what happened, for humans?* Trace log and
crash dumps. Never allowed to affect decisions.
Plus small markers (session identity, completion, quarantine) that steer the next
process start.
# Why a signal log AND an event spool?
They look similar (both JSONL) but serve opposite masters:
- The **signal log** is the *input* truth of the decision engine. Recovery rebuilds
decision state by replaying it; the snapshot is only a cache. An append failure here
**blocks the reducer** — a signal that was never durably logged must not change state,
or a post-crash replay would diverge from what the live run decided (determinism L.1/L.5).
- The **spool** is a *projection* for upload. Writing it is best-effort — a spool
failure must never break ingress, and a lost non-critical item is acceptable because
the backend dedups on `(PartitionKey, RowKey)`.
Same data, two files, because durability requirements differ: the signal log fsyncs
every line; the spool fsyncs only items flagged `RequiresImmediateFlush`.
# Artifact inventory
| Artifact | Path | Format | fsync | Rotation/cap |
| --- | --- | --- | --- | --- |
| Signal log | `State\signal-log.jsonl` | JSONL (`DecisionSignal`/line) | every append (WriteThrough + Flush, bufferSize=1) | none (per-enrollment lifecycle) |
| Journal | `State\journal.jsonl` | JSONL (`DecisionTransition`/line) | every append | atomic truncate only (phantom tail) |
| Snapshot | `State\snapshot.json` | JSON + SHA-256 checksum envelope | temp-write + `File.Replace` | single file |
| Event sequence | `State\event-sequence.json` | `{ LastAssignedSequence }` | atomic | none; survives WhiteGlove Part-2 resume |
| Spool | `Spool\spool.jsonl` | JSONL (`TelemetryItem`/line) | only `RequiresImmediateFlush` items | none — never truncated, cursor skips uploaded items |
| Upload cursor | `Spool\upload-cursor.json` | `{ LastUploadedItemId }` | atomic | idempotent, never regresses |
| Agent trace log | `Logs\agent.log` (segments `agent-NNN.log`) | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
| Crash dump + record | `Crashes\<stem>.dmp` + `.json` | MiniDump + JSON | n/a (process dying) | 5 files / 7 days |
| Startup-event gate | `State\startup-event-state.json` | JSON dict | no (atomic) | cross-reboot dedup of one-shot probes |
| Bandwidth state | `State\bandwidth-state.json` | JSON samples/counters | no (atomic) | reboot-survivor for the DO bandwidth estimate |
| 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 |
| Config cache | `Config\remote-config.json` | JSON | no | security-sensitive fields stripped |
Per-collector state files (`OfficeInstallStatePersistence`, IME tracker state, script
execution state, log-file positions) follow the same fail-soft atomic-replace pattern.
Cleanup (`CleanupService`) removes the agent dirs on self-destruct but **keeps `Logs\`**
for forensics.
# The decision-truth triad
## Signal log — authoritative input
- Written by the single ingress worker, one line per accepted signal, **before** the
reducer runs. Monotonic-ordinal guard on append.
- `bufferSize: 1` is load-bearing: session `b9b92d89` (2026-07-09) was killed mid-append
by a self-update `Environment.Exit`; the default 4096-byte buffer flushed the same
buffered line twice → byte-identical duplicate → non-monotonic ordinal → **recovery
crash-loop**. Fixes (commits `57e4334b` + `a0238a4b`):
- unbuffered append (nothing left for `Dispose` to re-flush),
- read-side tolerance: a byte-identical *consecutive* duplicate line is skipped as a
known crash artifact,
- self-update restarts now route through graceful shutdown (full drain, bounded 15 s
hard-exit fallback) instead of `Environment.Exit`,
- poison-replay quarantine (below) so a corrupt log can never loop the agent again.
- Corrupt-tail rule: readers stop at the first unparsable line (a torn final write is
expected, not fatal).
## Journal — authoritative applied-transition record
One `DecisionTransition` per reducer step, appended in `ApplyStep` step 1 — the **only**
persistence path that hard-throws. Three consecutive append failures escalate to a
quarantine request. Used during recovery to detect BEHIND/AHEAD crash windows against
the snapshot; AHEAD phantom entries are truncated to `.quarantine\<ts>\journal-phantom-tail.jsonl`.
## Snapshot — recovery cache, never truth
Full `DecisionState` (schema **v4**) in a checksum envelope, written best-effort after
meaningful steps (stage transition / non-telemetry effect) or every 100 pass-through
steps. Atomic temp-write + `File.Replace`; a crash mid-save leaves the old file intact.
Load returns `null` on any doubt (missing, IO error, checksum mismatch) — recovery then
falls back to full signal-log replay. Aborted/phantom states are never snapshotted.
# Recovery flow on restart
`RecoveryCoordinator.Recover` runs before the live pipeline; order is load-bearing:
1. **Death-rattle peek**: if the previous exit was unclean, read the prior snapshot
*non-mutatingly* (`TryReadRaw`) for the `previous_crash_detected` context — before
the pipeline may quarantine it.
2. **Quarantine marker** (`quarantine-requested.marker`, written mid-run when the
journal became unappendable): quarantine all segments + snapshot *now*, at a moment
when no writer holds them. Marker is deleted only on full success.
3. **WhiteGlove Part-2 resume**: prior stage `WhiteGloveSealed` → archive the reducer
segments to `.part1-<ts>\` but **preserve `event-sequence.json`** (the Web UI splits
Part 1/2 on the sequence).
4. **Open writers** — each scans its file for the last ordinal/step, stopping at the
first unparsable line.
5. **Pick seed + replay stream**:
- snapshot loads → seed from it, re-stamp `AgentBootUtc` to *now* (replayed signals
must not arm past-due deadlines), replay only the signal-log **tail**
(`ordinal > snapshot.LastAppliedSignalOrdinal`);
- snapshot corrupt → quarantine snapshot only, replay the full signal log;
- log head-corrupt → total-loss quarantine of everything, fresh Initial seed.
6. **Journal alignment**: truncate AHEAD phantoms to the seed's `StepIndex`.
7. **Replay + backfill**: fold the stream through a transient `DecisionEngine`,
re-appending any missing journal entries (closes the BEHIND window).
8. **Poison guard**: if replay itself throws, quarantine snapshot + all segments and
reseed Initial — the agent keeps running with fresh state instead of crash-looping.
**Duplicate avoidance, end to end**: monotonic append guards (signal log + journal) →
consecutive-duplicate skip on read → tail-replay past the snapshot ordinal → AHEAD
truncation → backend `(PartitionKey, RowKey)` dedup for re-uploaded spool items.
**Quarantine semantics**: files are *moved*, never deleted, into
`State\.quarantine\<utc-ts>\` with a `reason.txt` sidecar — evidence survives for
diagnostics ZIPs while the agent restarts from clean state.
# Related
- Pipeline that produces these files: [runtime overview](overview.md)
- What replay must reproduce deterministically: [decision engine](decision-engine.md)
-100
View File
@@ -1,100 +0,0 @@
---
type: Concept
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
tags:
- agent
- esp
- reboot
- policy-attribution
- second-sign-in
timestamp: 2026-07-20T00:00:00+02:00
---
# MDM Reboot Coalescing
Device-assigned MDM policies whose CSP URIs match the OS reboot-required catalog
(`HKLM\SOFTWARE\Microsoft\Provisioning\SyncML\RebootRequiredURIs`) force a **coalesced
reboot at the end of ESP DeviceSetup** — the user sees an unexpected restart and a
**second sign-in screen** before AccountSetup. Invisible in the Intune console; most
admins live with it, not knowing that reassigning the profiles to user groups removes the
reboot entirely. Source research: Patch My PC,
["Avoid the second sign-in screen after Autopilot"](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/).
The session already counts reboots (`system_reboot_detected` → `session.rebootCount`);
this feature adds the **cause attribution**.
# Verified signal facts (session b2e890c1, 2026-07-20)
* **EventID 2800**, `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin`,
one record per URI, verified description shape:
`The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).`
Records arrive in sub-second bursts during policy sync.
* **Timing semantics (the critical nuance)**: 2800 fires at EVERY policy sync. Only URIs
applied **during ESP DeviceSetup** are coalesced into the forced mid-ESP restart. In the
verified session the flags fired during AccountSetup (after DeviceSetup completed) —
**no reboot occurred**, and `omadmRebootRequiredFlag` read `false`. A detector that
claims "reboot incoming" on every 2800 is therefore wrong.
* **Shell-Core 62407 "RebootCoalescing" — REMOVED as a signal.** The token appears in the
ROUTINE bootstrap marker on every enrollment:
`Name: 'CommercialOOBE_BootstrapStatusCategory_SubcategoryProcessing_Started', Value: '{"message":"BootstrapStatus: Starting subcategory DeviceSetup.RebootCoalescing...","errorCode":0}'`
— the ESP merely *processes* the RebootCoalescing subcategory (which checks whether a
reboot is needed). A substring match on it is a false positive by construction. If a
real coalesced-reboot record is ever captured on the E2E VM, a precise pattern can be
added then.
# Detector — `MdmRebootPolicyTracker` (`MdmRebootPolicyWatcherHost`)
Live `EventLogWatcher` on the DM-Enterprise Admin channel (EventID 2800) + startup
backfill (default 60 min lookback). Records are **buffered and debounced (10 s)**, then
flushed as **ONE aggregated `mdm_policy_reboot_required` event** per burst:
* `Severity=Info`, neutral message ("Windows flagged N MDM policy URIs as
reboot-required: … (+N more)") — the raw event never claims a reboot.
* `Data`: `rebootUris` (distinct, sorted, capped 20), `uriCount` (uncapped distinct),
`firstRebootUri` (record-time order, drives rule interpolation), `recordCount`,
`unparsedCount`/`sampleDescription` (extraction diagnostics), `backfilled` (all-backfill),
fail-soft `omadmRebootRequiredFlag` probe of
`HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\SyncML\RebootRequired` (null = unknown).
* `Timestamp` = earliest record time (backfilled bursts land at their historical instant);
`ImmediateUpload=true` (one event per burst is cheap, and a DeviceSetup burst precedes a
reboot that kills the agent).
* **Watermark is persisted at FLUSH, not at claim** (`mdm-reboot-watermark.json`): if the
coalesced reboot kills the process before the debounced emit, nothing is marked — the
post-restart backfill re-reads and emits the burst. A flushed burst is never repeated.
URI extraction: EventData values starting with `./` preferred, permissive regex over the
description as fallback (trailing punctuation trimmed — the verified text wraps the URI in
parens and ends with a period). Unparsed records still count into the aggregate.
Config (internal steering only, no admin UI — agent-native collector, NOT an
`IAgentAnalyzer`): `Collectors.MdmRebootPolicyWatcherEnabled` (default true),
`Collectors.MdmRebootPolicyBackfillLookbackMinutes` (default 60). ConfigVersion 35.
# Advisory — ANALYZE-ESP-005
The reboot **claim** lives here, and only here. Required conditions (BOTH):
1. `mdm_policy_reboot_required.firstRebootUri` exists (→ `{{firstRebootUri}}` interpolation)
2. `system_reboot_detected` count ≥ 1 — **an actually-observed reboot**
Policy flags alone (e.g. AccountSetup user-policy sync) stay advisory-silent. Optional
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)
-153
View File
@@ -1,153 +0,0 @@
---
type: Component Overview
title: V2 Agent — Runtime Overview
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2/Program.cs
tags:
- agent
- runtime
- collectors
- telemetry
timestamp: 2026-07-11T00:00:00+02:00
---
# V2 Agent — Runtime Overview
The agent is a .NET Framework 4.8 executable (`AutopilotMonitor.Agent.exe`) that runs as
SYSTEM on Windows devices during Autopilot enrollment. It observes the enrollment
(ESP phases, IME app installs, Hello, desktop arrival, …), streams telemetry to the
backend, and decides locally — via the [decision engine](decision-engine.md) —
when the enrollment is complete or failed. When it reaches a terminal decision it shows
the optional summary dialog, uploads diagnostics, and removes itself.
Projects: `src/Agent/AutopilotMonitor.Agent.V2` (exe) + `AutopilotMonitor.Agent.V2.Core`
(library) + `src/Shared/AutopilotMonitor.DecisionCore` (portable decision engine).
# Lifecycle
## Install (bootstrap during OOBE)
`AutopilotMonitor.Agent.exe --install` is invoked by the Intune Platform Script
(bootstrap) and never runs the monitoring runtime
(`src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs`):
1. Copy payload to `%ProgramData%\AutopilotMonitor\Agent\`.
2. Persist `bootstrap-config.json` (bootstrap token, tenant id) and `await-enrollment.json`.
3. Register the `AutopilotMonitor-Agent` scheduled task (BootTrigger, SYSTEM,
`ExecutionTimeLimit=PT0S`, survives battery conditions).
4. Immediate runtime handoff: WMI `Win32_Process.Create` → `schtasks /Run` fallback →
defer to BootTrigger on next reboot. `--install` exits 0 even if both immediate
launches fail (fail-soft; the BootTrigger is the net).
5. Write `HKLM\SOFTWARE\AutopilotMonitor\Deployed` = UTC timestamp — the **bootstrap
re-entry lock** read by the install script. Never consulted by the agent itself;
intentionally preserved across cleanup.
## Runtime start (each process start / boot)
`Program.Main` → `RunAgent` runs numbered phases (V1-parity exit codes 0–7):
1. **Guards & bootstrap** (`AgentBootstrap.Run`): classify previous exit
(`clean` / `exception_crash` / `hard_kill` / `reboot_kill` via `clean-exit.marker`,
crash logs, boot event 6009); ghost-restart guard (`enrollment-complete.marker`
→ cleanup + exit 0); absolute session-age emergency break; optional waits
(`--await-enrollment` MDM cert poll, `--tenant-id-wait` registry wait); resolve
TenantId from `HKLM:\SOFTWARE\Microsoft\Enrollments`; get-or-create SessionId.
2. **Auth clients** (mTLS with the Intune MDM device client certificate).
3. **Remote config** (`GET /api/agent/config`): live fetch → cached
`Config\remote-config.json` → built-in defaults. Config-channel **kill switch**
checked here, honoured only from a *live* fetch (cache strips all security-sensitive
fields).
4. **Telemetry clients**, **backend session registration**.
5. **`AgentRuntimeHost.Run`**: builds the `EnrollmentOrchestrator`, starts collector
hosts, decision pipeline and upload drain loop, then blocks until shutdown.
## Stop triggers
- **Terminal decision** of the engine (Completed / Failed / WhiteGloveSealed) — the normal path.
- **Max-lifetime watchdog**: `AgentMaxLifetimeMinutes` (default 360) — hard cap per session.
- Backend **kill switch** (config channel at start; telemetry channel via upload responses
→ synthesized `terminate_session` action).
- Auth-failure threshold, self-update restart, Ctrl+C / ProcessExit.
Distinct from agent lifetime: `CollectorIdleTimeoutMinutes` (default 15) stops only the
periodic collectors (Performance, AgentSelfMetrics) when nothing "real" happens; they
restart on new activity.
## Termination sequence
On terminal decision (`EnrollmentTerminationHandler`): stop collectors → build
`FinalStatus` → write `final-status.json` + launch `SummaryDialog` (separate WPF exe in
the user session, opt-in) → 2 s late-event grace → diagnostics ZIP upload →
write `enrollment-complete.marker` → optional reboot → self-destruct cleanup
(skipped for WhiteGlove Part 1, which instead writes the `whiteglove.complete` marker
and leaves state for Part 2).
# Collector model
Collectors are wrapped in **collector hosts** (`ICollectorHost`, built by
`DefaultComponentFactory.CreateCollectorHosts`). Each host owns collector/tracker
classes plus a **signal adapter** that translates raw callbacks into decision signals.
Main hosts (gating in parentheses; several more are opt-in via remote config):
| Host | Watches |
| --- | --- |
| EspAndHelloHost | ESP phases, Shell-Core events, provisioning status, Hello (always) |
| ImeLogHost | IME log tailer (CMTrace) + IME process watcher — app installs (always) |
| DesktopArrivalHost | real-user desktop arrival (always) |
| AadJoinHost | AAD join events + hybrid login pending (always) |
| DeviceInfoHost | one-shot hardware/OS/network inventory (always) |
| StallProbeHost | idle/stall probes (config) |
| WindowsUpdateWatcherHost / OsBuildChangeHost | WU during OOBE, build changes (config / always) |
| DeliveryOptimizationHost | DO counters for bandwidth estimate (config, dormant until installs run) |
| OfficeInstallDetectorHost, ConsoleBypassHost, ProvisioningPackageHost, NetworkChangeHost, RealmJoinHost, GatherRuleExecutorHost, … | feature-specific (mixed gating) |
# Event pipeline (single rail)
Everything a collector observes flows through **one** path — no collector emits
telemetry directly:
```
Collector callback
→ SignalAdapter (dual emission: decision signal + InformationalEvent)
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer)
→ single worker thread:
assign ordinals → SignalLog.Append (durable) ← source of truth
→ DecisionEngine.Reduce → apply step/effects
→ telemetry projection (best-effort)
→ TelemetrySpool → batch (≤100) → gzip → POST /api/agent/telemetry
```
- Wire model: `EnrollmentEvent` (`EventType` from `Constants.EventTypes`, monotonic
`Sequence` = canonical order, `Phase`, `ImmediateUpload` for lifecycle-critical items),
wrapped in `TelemetryItem` (Kind = Event / Signal / DecisionTransition, monotonic
`TelemetryItemId` as upload cursor).
- Auth per request: client-cert mTLS + hardware headers (`X-Tenant-Id`,
`X-Device-Manufacturer/-Model/-SerialNumber`, `X-Bootstrap-Token` pre-MDM-cert,
`X-Agent-Version`).
- Retry: 100/400/1600 ms for transient (408/429/5xx); 413 → split batch and halve batch
size; unknown 4xx → items retained (cursor holds) unless the backend flags explicit
poison row keys; offline → spool keeps everything, backend dedups on re-upload;
`DeviceBlocked` pauses the drain until `UnblockAt`.
# Decision integration
Adapters post `DecisionSignal`s; the engine's terminal stage fires
`Terminated` (off the worker thread) → termination sequence above. Engine *effects*
(timeline events like `enrollment_complete`, deadline scheduling, classifier runs,
snapshots) are executed by the `EffectRunner` — the reducer itself is pure. Details:
[decision engine](decision-engine.md); durability and replay:
[logs & persistence](logs-and-persistence.md).
# On-disk layout (`%ProgramData%\AutopilotMonitor\`)
| Path | Contents |
| --- | --- |
| `\` | `bootstrap-config.json`, `await-enrollment.json`, `clean-exit.marker`, session-id persistence |
| `\Agent\` | deployed binaries |
| `\State\` | decision persistence (SignalLog, Journal, Snapshot), `enrollment-complete.marker`, `final-status.json`, self-update state, `.quarantine/`, `.part1-<ts>/` |
| `\Spool\` | telemetry spool (offline queue) |
| `\Logs\` | agent trace log (`agent.log`), `crash-*.log`, `ime-pattern-matches.log` |
| `\Config\` | `remote-config.json` cache |
| `\Agent-Update\`, `\Updates\` | self-update staging/download |
Per-file details and the recovery flow: [logs & persistence](logs-and-persistence.md).
@@ -1,200 +0,0 @@
---
type: Concept
title: ESP Completion Starvation — the sits-d Cloud-PC complex
description: Why five Windows 365 Cloud PCs finished enrolling but never completed in the product — a SkipUser=true flow produces none of the signals every completion-gate evaluation site hangs off — plus the two adjacent defects fixed alongside (edge-only user-apps-settled synthesis, orphaned Hello-wizard restart replay) and what a Shell-Core replay deliberately does not do.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
tags:
- agent
- esp
- completion
- skip-user
- reboot
- recovery
timestamp: 2026-08-20T00:00:00+02:00
---
# ESP Completion Starvation — the sits-d Cloud-PC complex
Seven Windows 365 Cloud PCs, same tenant, first sign-in the same morning. Two sessions
succeeded within minutes; five sat at `Completion is waiting on: hello_resolution` from
second five onward and ran into the six-hour max-lifetime watchdog — verdict `Incomplete`
on devices whose users were working normally the whole time.
The first diagnosis blamed the mid-ESP reboot (DeviceGuard/DmaGuard, see
[MDM Reboot Coalescing](mdm-reboot-coalescing.md)): the agent is killed in
`Stage=EspAccountSetup` and, so the theory went, loses the Shell-Core ESP exit to the
downtime. The evidence disproved it. `8110e262` ran **six hours uninterrupted** after its
last reboot with the Shell-Core watcher armed — and Windows never wrote a 62407 in all
that time, because on these machines **the user ESP page never existed**.
# Schema
## The actual defect — a satisfied gate nobody evaluates
The five stuck machines carried `SkipUserStatusPage=True` in the enrollment `FirstSync`
key (read 10+ times across three agent processes, `True` on every read — not a stale
single read). Windows honours it: no user ESP page, hence
* no Shell-Core 62407 (`esp_exiting`) — the page that would exit does not exist,
* no `AccountSetupCategory` provisioning registry — nothing writes categories for a page
that never renders,
* desktop within seconds of sign-in.
The reducer is *built* for this flow: **arm B** of `ShouldTransitionToAwaitingHello`
(`SkipUserEsp == true`) declares the gate open, and the missing-prerequisites bookkeeping
already exempts the AccountSetup gate — which is why the sessions reported only
`hello_resolution` as missing. But the gate is a predicate, not a process. Something has
to evaluate it, and every evaluation site hangs off a signal a skip-user flow structurally
cannot produce:
| Evaluation site | Carrier signal | On SkipUser=true |
|---|---|---|
| `HandleEspExitingV1` | Shell-Core 62407 | never fires |
| `HandleEspPhaseChangedV1(FinalizingSetup)` | Shell-Core 62407 via coordinator | never fires |
| `HandleDesktopArrivedV1` fast-path | requires Hello policy **disabled** | Hello was enabled |
| `HandleImeUserSessionCompletedV1` arm-C attempt | requires the recorded final exit | unsatisfiable |
No knock, no promotion, no `HelloSafety` — and the `AdvisoryCompletion` backstop is armed
exclusively from an `EspExiting` signal, so it never armed either. The session idles to
the watchdog with an open gate.
## The fix — the observed skip is the exit evidence
`HandleImeUserSessionCompletedV1`'s completion attempt now accepts the observed skip in
place of the final exit (`skipUserExitEquivalent`): on a flow where no page exists, no
exit can ever say more than the `FirstSync` read already did. Everything else stays
mandatory — AccountSetup anchor, genuine (at-or-after-anchor) IME user-session completion,
DAD-validated real-user desktop — which makes this knock **stricter** than the existing
Shell-Core-carried arm-B promotion (pinned in `ClassicAwaitingHelloGuardTests`, which
requires no IME or desktop evidence at all). Hello semantics ride the existing rails:
enabled-or-unknown promotes to `AwaitingHello` with `HelloSafety` armed (on Cloud PCs
reached via RDP the Hello wizard never appears, so the synthetic timeout resolves the
wait), disabled completes directly with the synthetic `Skipped` outcome.
The knock deliberately lives on the IME user-session edge, not on desktop arrival:
* **Ordering-robust against Fix 10.** An `EspPhaseChanged(AccountSetup)` arriving on
`AwaitingHello` bounces the stage back and cancels `HelloSafety` (the premature-promotion
guard). The IME AccountSetup phase line lands *after* the desktop on this flow, so a
desktop-side knock would be undone two signals later. The IME phase line always precedes
the user-session-complete line in log order, so a bounced promotion is re-knocked by the
very next re-emission — including the restart re-parse.
* **Right semantics.** The IME user session completes when the required-app processing is
done; a desktop-side knock would mark the session `Succeeded` mid-installation.
* Device Preparation is excluded — WDP keeps its Hello+Desktop conjunction and the
`DevicePrepCompletion` backstop.
`ClassicSkipUserEspUserSessionCompletionTests` replays the real `8110e262` signal ordering
end to end (bootstrap `EspConfigDetected` → Hello enabled → desktop → AccountSetup anchor →
IME completion → HelloSafety → `enrollment_complete`), including the Fix-10
bounce-then-re-knock cycle, and pins the negative space (no skip observed / no desktop /
ghost pre-anchor IME completion / WDP).
Why five of seven identically administered Cloud PCs carried `SkipUserStatusPage=True` is
a tenant-side question (ESP profile assignment state at each machine's provisioning
moment); the value did not change during any observed session, and machine age does not
cleanly explain the split. The agent's job is to complete the flow Windows actually ran —
which it now does.
## Adjacent defect — the synthesis was edge-triggered
This one is real but belongs to the `SkipUser=false` flow (the two sessions that
succeeded). The user-apps-settled synthesis
(`MaybeSynthesizeAccountSetupCompleteFromSettledUserApps`, session `caa6cf50`) ran **only**
from `OnEspExited`. On a tenant with ~138 required apps the ESP page exits while apps are
still in flight: the single attempt misses and nothing ever re-checks, even when every
tracked app reaches a terminal state minutes later.
The ESP exit is an **edge** (happens once); settled user-ESP apps are a **level** (reached
later). `EspAndHelloTracker` records the edge in `_espExitObserved` and exposes
`ReevaluateUserAppsSettledSynthesis()`, chained into `ImeLogTracker.OnAppStateChanged` and
into the post-restore `onStateRestored` callback (a live exit observed before the IME
state restore completes would otherwise never get a second look). Gate conditions are
unchanged — no new completion path, only a second opportunity for the existing one. Only a
**live, confirmed post-AccountSetup** exit may be remembered
(`IsConfirmedPostAccountSetupExit`, stricter than the `IsIntermediateDeviceEspExit`
forward guard: unknown counts as not confirmed).
## Adjacent defect — the restart replay was never wired up
`ShellCoreTracker`'s backfill had existed since session `772fe502` with **no caller**; the
Shell-Core `EventLogWatcher` only delivers records written after `Start()`.
`EspAndHelloHost.Start()` now calls it as `BackfillRecentHelloWizardStart` — which is
exactly what it does and all it does:
| Record | Replayed | Why |
|---|---|---|
| 62404 Hello-wizard start | **yes** | Conservative: vetoes a premature "Hello is disabled" skip, can never complete a session by itself. The observation `772fe502` was about. |
| 62407 ESP exit | **no** | Cannot be placed in time — see below. |
| 62407 ESP failure | **no** | Re-injecting a historic failure as fresh can fail a session that recovered on retry (`ANALYZE-ESP-006`). |
A replayed exit is unusable, and every candidate ordering mechanism fails independently:
1. Windows writes the **identical** description `CommercialOOBE_ESPProgress_Page_Exiting`
for the intermediate DeviceSetup→AccountSetup transition and the final exit.
2. Every post-hoc ordering probe (AccountSetup registry, settled-apps) reads state as it
is **now**, not as it was at the event's time.
3. The reducer orders exits by **ingest ordinal**, not timestamp
(`IsPostAccountSetupFinalExit`) — a historic exit replayed today gets a fresher ordinal
than reality and reads as post-AccountSetup by construction.
4. `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit; with
restored state, arm C would open on a historic intermediate exit.
`ClassicEspExitingOnRestoredStateTests` pins that the reducer *does* open on any arriving
exit; `ShellCoreTrackerReplayScopeTests` pins that the replay never produces one. Skipped
62407 records are counted and reported once as an `agent_trace`
(`reason: replayed_62407_not_orderable`) — silently dropping evidence is how this class of
bug survives.
**Residual gap, named honestly:** a `SkipUser=false` session whose final exit falls
exactly into an agent downtime *and* whose AccountSetup category never resolves
(`1ec8f4c6` shape) still has no completion carrier after the restart. The normal case is
covered — `ProvisioningStatusTracker` reads the current registry at startup, so a
category that resolved during the downtime fires arm A. The residual shape is rare,
surfaced by `ANALYZE-ESP-005` via the `session_stalled` interim trigger, and deliberately
not patched with a replay that cannot be ordered.
### Sizing the replay window
A constant is provably wrong: the scheduled task has a `BootTrigger` only
(`Program.InstallMode.BuildScheduledTaskXml`) — after an `exception_crash` the agent does
not return until the next boot. `ResolveEspExitBackfillLookbackMinutes` reaches back to
the last moment the previous run is known to have been alive (max of `snapshot.json`
mtime and `LastBootUtc` for kill-type exits), floored at 5 minutes, capped at 360, off on
`first_run`, and immune to cross-reboot clock skew (a future timestamp is ignored).
# Examples
Tenant `sits-d.cloud`, 2026-08-19, seven Windows 365 Cloud PCs, first sign-ins the same
morning. The discriminator is a single registry value:
| Session | SkipUser | `esp_exiting` | AccountSetup registry | Reboots | Outcome |
|---|---|---|---|---|---|
| `08fc6bda` | False | yes | yes | 0 | Succeeded (10 min) |
| `3b9291aa` | False | yes | yes | 0 | Succeeded (2.1 h) |
| `8110e262` | True | — | — | 2 | Incomplete (6 h watchdog) |
| `a89aac2d` | True | — | — | 1 | Incomplete |
| `e7ba63c9` | True | — | — | 1 | Incomplete |
| `cb4a485a` | True | — | — | 2 | Incomplete |
| `3d6278fb` | True | — | — | 1 | Incomplete |
The reboot correlation was a red herring: `8110e262` ran six hours live after its final
reboot and would have seen every signal. `8110e262` and `a89aac2d` both reached
`138/138 completed, 0 failed` and a genuine IME user-session completion — with the fix,
all five complete a few minutes after their IME user session settles.
`08fc6bda` exercises the edge-level fix on the same day: ESP page exited normally while
`categorySucceeded` never confirmed — completed via the user-apps-settled synthesis.
`3b9291aa` shows the independent starvation visibility gap: 99 `app_install_starved`
events on a session recorded as `Succeeded` — surfaced from now on by `ANALYZE-APP-016`.
# Citations
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` — `HandleImeUserSessionCompletedV1`, `skipUserExitEquivalent`
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs` — `ShouldTransitionToAwaitingHello` (arm B)
* `src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicSkipUserEspUserSessionCompletionTests.cs` — end-to-end replay of the 8110e262 ordering
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs` — `BackfillRecentHelloWizardStart`, `ReplayBackfillRecords`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs` — `_espExitObserved`, `ReevaluateUserAppsSettledSynthesis`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs` — lookback sizing, `OnAppStateChanged` chaining
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) — the policy attribution for the reboots themselves
* [Decision Engine](decision-engine.md) — the AccountSetup gate and completion arms
-106
View File
@@ -1,106 +0,0 @@
---
type: Concept
title: Power-State Watcher — live AC/battery transitions and threshold events during enrollment
description: How the agent observes AC↔battery switches and downward battery-threshold crossings mid-enrollment via a WMI push subscription (no polling), the power_state_change event semantics (transition field, 50/30/15 ladder, latch/debounce/cap), and the two analyze rules on top (ANALYZE-DEV-009/-010).
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs
tags:
- agent
- power
- battery
- collector
- wmi
- analyze-rules
timestamp: 2026-08-21T00:00:00+02:00
---
# Power-State Watcher
The startup probe (`power_state_check`, one-shot from `StartupEnvironmentProbes` via
`PowerStateProbe`/`GetSystemPowerStatus`) only captures the power state at agent start. A
device unplugged mid-enrollment — or draining its battery across a multi-hour ESP — was
invisible. The watcher closes that gap **event-driven, with zero polling**.
# Mechanism
- `PowerStateWatcherHost` (`Orchestration/Hosts`) arms a WMI event subscription with a
two-step chain:
1. `SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE:
true kernel push, fires on AC↔DC switches and battery-percentage steps).
2. **Fallback** `SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance
ISA 'Win32_Battery'` — required because on Windows 11 24H2/25H2 (observed on builds
26200 and 26220, session 161b838c) the `MS_Power_Management_Event_Provider` is still
registered in the repository but its activation fails with `WBEM_E_NOT_FOUND`: the
legacy push provider no longer loads on exactly the OS builds new devices enroll with.
The WITHIN sampling runs inside WinMgmt (one Win32_Battery snapshot every 30 s), not in
the agent; worst-case detection latency ≈ 35 s incl. debounce. `collector_degraded`
(`watcher_arm_failed`) is emitted only when BOTH attempts fail; the agent log's arm
line names the active mode.
Either way the arriving event's payload is ignored — each arrival re-probes via the
existing `PowerStateProbe.Probe()` (cheap P/Invoke).
- Snapshots are diffed by the pure `PowerStateTransitionTracker`
(`Monitoring/Enrollment/SystemSignals`), which decides what to emit. The host only owns
WMI, a 5 s trailing-edge debounce timer (dock-flap AC→battery→AC inside the window
collapses to a no-diff) and the emit path (`InformationalEventPost`, single rail).
- **No battery / probe error ⇒ WMI is never armed** — desktops and VMs pay nothing. WMI
arming runs on a background thread (WinMgmt can hang in OOBE; `ConsoleBypassWatcher`
precedent); an arm failure emits one `collector_degraded` (`watcher_arm_failed`).
- Registered as a plain always-on peripheral host after `NetworkChangeHost`, deliberately
NOT under `PeriodicCollectorLifecycleHost`: it is push-based (zero idle cost) and must be
alive precisely when the device idles on battery. Kill-switch:
`Collectors.EnablePowerStateWatcher` (default true, compile-time default like the other
collector toggles; ConfigVersion 38).
# Event: power_state_change
`data.transition` discriminates three shapes (payload always carries `onAcPower`,
`batteryPercent`, `isCharging`, `batteryLifeMinutes`; unknowns as `"unknown"`):
| transition | Severity | Immediate | Notes |
|---|---|---|---|
| `ac_to_battery` | Warning | yes | one per accepted edge |
| `battery_to_ac` | Info | no | recovery |
| `threshold_crossed` | Info (50) / Warning (30) / Error (15) | 30+15 only | extra field `thresholdPercent` |
Threshold semantics (tracker):
- Ladder 50/30/15, evaluated only while ON battery; **each level latches once per agent
lifetime** (charge-up + re-drain does not re-emit — no plug-cycle alarm spam).
- A multi-level jump emits only the LOWEST newly-crossed level (60%→10% ⇒ one `15` event).
- Entering battery power already below a level counts as crossing it; the arm-time baseline
does too — an enrollment STARTING at 12% battery emits its `thresholdPercent: 15` event
immediately (the analyze rule would otherwise never see sessions that began low).
- Lifetime emission cap 20 (storm backstop against a flapping dock / dying battery
controller); the host logs one warning when it engages.
Backend/agent hygiene: `power_state_change` is classified as non-activity on both sides —
`SignalActivityClassifier.NonActivityEventTypes` (agent: must not reset the
PeriodicCollectorLifecycleHost/StallProbeHost idle clocks) and
`EventIngestProcessor.IsPeriodicOrStallEvent` (backend: must not heal a Stalled session —
battery drain is not enrollment progress).
# Analyze Rules
- **ANALYZE-DEV-009** (high): battery crossed 15% while on battery — matches the
agent-stamped `thresholdPercent equals 15`.
- **ANALYZE-DEV-010** (warning): AC→battery switch — matches `transition equals
ac_to_battery`.
- Both: `evaluateOn: ["enrollment_end", "whiteglove_sealed", "on_event:power_state_change"]`
(live interim finding + terminal finalization), `markSessionAsFailedDefault: false`.
- Both are deliberately **single-required-condition**: `event_data` conditions scan events
independently (no same-instance join), so a two-condition rule (e.g. `transition ==
threshold_crossed AND batteryPercent < 15`) could false-positive across two different
power events. `thresholdPercent` exists only on threshold events and its value pins the
ladder level, so one condition fully identifies the instance.
- `{{token}}` caveat: `AddDataFieldsToEvidence` whitelists only generic identifiers
(appId/errorCode/…), so `{{batteryPercent}}` would render literally. DEV-009 interpolates
`{{thresholdPercent}}` (the matched condition's own dataField); the exact percentage lives
on the linked event in the timeline.
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/PowerStateWatcherHost.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/PowerStateTransitionTracker.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/PowerStateProbe.cs` (probe, unchanged)
- `rules/analyze/ANALYZE-DEV-009.json`, `rules/analyze/ANALYZE-DEV-010.json`
- [analyze-rule-triggers](../rules/analyze-rule-triggers.md) (interim-trigger design; the WG battery rule was its motivating case)
-89
View File
@@ -1,89 +0,0 @@
---
type: concept
title: Registry App-State Second Pillar
description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
resource: agent
tags: [ime, registry, win32apps, observability, reconciliation, pattern-drift, wdp]
timestamp: 2026-08-18
---
# Schema
## Why a second pillar
The 2026-08-17 audit of the decompiled IME (1.97.107 / 1.104.102) proved that IME log
wording is an unstable contract: Microsoft removed the old V1/V2 processing pipeline
(killing 9 patterns silently), reworded the post-remediation line, changed the
report-delta quoting between 1.97 and 1.104, and deleted `[DO TEL]` entirely. The
registry, by contrast, is IME's *internal product contract* — the ESP page and Intune
reporting consume it — and cannot drift casually.
Architecture decision (Oliver, 2026-08-17): **hybrid**. Logs stay the narrative
(downloads, retries, exit codes, scripts — the timeline). The registry is the stable
truth for terminal state. Divergence between the two IS the alarm that log patterns
drifted — turning silent data loss into a Warning event.
## Observed surfaces
All under `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension` (64-bit view), verified
against decompiled IME source:
| Surface | Content |
|---|---|
| `Win32Apps\<userGuid>\<appGuid>_<rev>` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. |
| `EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev>` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. |
| `SideCarPolicies\StatusServiceReports\<userGuid>\<appId>` | The `AppInstallStatusReport` the ESP page renders (`Status`: 1000=Installed, 1001=Installing, 3000=Failed, 2000er=NotApplicable). |
## Mechanism
`ImeRegistryAppStateObserver` + `ImeRegistryAppStateHost` (always-on, no config gate —
precedent `EspPolicyProviderStallHost`):
- **One recursive `RegistryWatcher`** on the IME root covers all three surfaces;
changes are debounced (2 s trailing edge) into a tick. A 60-s periodic tick is both
the reconciliation-settle evaluator and the poll-only fallback when the watcher
fails to arm (`collector_degraded`, once).
- **Snapshot-and-diff**: `RegNotifyChangeKeyValue` gives coalesced key-scope edges
only, so every tick re-reads the surfaces and diffs against the previous snapshot.
- **Silent baseline**: the state present at agent start is captured without events.
Win32Apps keys survive re-enrollments — replaying them would be the registry twin
of the historic-IME-replay bug. Only apps that *change during the session* are
reported or judged.
- **`registry_app_state`** (Info): emitted per real field change
(enforcementState/errorCode/exitCode/statusServiceStatus/espTracked/espPhase),
capped at 200/session (cap announced once as Warning).
- **`app_state_reconciliation`** (Warning, once per app, immediate upload): after a
changed entry has been terminal (EnforcementState 1000er=success / 5000er=error,
or StatusService 1000er/3000) for ≥ 90 s, its outcome is compared against the
log-derived `AppPackageState` from `ImeLogHost.AllKnownPackageStates`. Divergence
reasons: `registry_error_log_installed`, `registry_success_log_error`,
`app_unknown_to_log_tracking` (only when the tracker is actively tracking apps —
an idle tracker is not judgeable).
- **Pure observability**: nothing feeds the DecisionEngine (same rule as
oobe-state observability).
## WDP reuse (Paket 3)
The same host archetype (RegistryWatcher subtree + debounce + snapshot-diff) is the
intended vehicle for WDP live progress: watch
`HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DevicePreparation\BootstrapperAgent`
(`ProgressUpdates` rows, `ResumeContext.ProvisioningResult` terminal marker) with the
RealmJoinWatcher appearance-watcher pattern for not-yet-existing keys, plus an
Event-142 channel watcher (`Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider`,
channel handling precedent in `DiagnosticsPackageService`). Win32Apps keys are written
on WDP devices too — the pillar works on both rails unchanged.
# Examples
Timeline evidence of a drifted pattern set: apps install fine (registry says
EnforcementState 1000) but the timeline shows no `app_install_completed` — each app
raises one `app_state_reconciliation` with `reason=app_unknown_to_log_tracking`.
Operator response: run `/ime-pattern-validate` against the session, fix patterns,
backend-deploy (no agent release needed for pattern fixes).
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs`
- `tasks/ime-v1-code-audit-2026-08-17.md` (decompiled-source audit; registry surface catalog)
- Decompile archive: `okieselbach/ime-decompiles` (private), `C:\Code\GitHubRepos\ime-decompiles\<version>\decompiled`
-100
View File
@@ -1,100 +0,0 @@
---
type: Concept
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.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/UefiSecureBootCertReader.cs
tags:
- agent
- secureboot
- ca2023
- analyze-rules
- backward-compatibility
timestamp: 2026-07-23T00:00:00+02:00
---
# Secure Boot CA-2023 Detection
## Schema
Two independent evidence sources describe the Windows UEFI CA 2023 rollout state:
1. **Servicing registry** (`HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing\UEFICA2023Status`)
— reflects whether the *Windows Update* rollout ran and self-verified. Lags reality:
devices whose firmware received the certificate another way (OEM factory image, WinCS
CLI, manual deployment) or where WU simply has not run yet show `unknown`/`notfound`.
2. **Firmware itself** — the UEFI variables `db` (signature database, GUID
`{d719b2cb-3d3a-4596-a3bc-dad00e67656f}`) and `KEK` (GUID
`{8be4df61-93ca-11d2-aa0d-00e098032b8c}`). Authoritative. Certificate subject CNs are
ASCII bytes inside the DER signature lists, so a byte-substring search for
`Windows UEFI CA 2023` / `Microsoft Corporation KEK 2K CA 2023` decides presence —
the same check `Get-SecureBootUEFI` enables from PowerShell.
`UefiSecureBootCertReader` (agent, `Monitoring/Interop`) reads both variables natively via
`GetFirmwareEnvironmentVariableExW` — no PowerShell runspace during OOBE. The call needs
`SeSystemEnvironmentPrivilege`: SYSTEM holds it default-disabled, so the reader enables it
on the process token (`AdjustTokenPrivileges`) and restores the previous state afterwards.
Fail-soft contract like `OobeStateReader`: never throws; failures map to status strings
(`not_uefi` = legacy BIOS via `ERROR_INVALID_FUNCTION`, `variable_not_found`,
`privilege_denied`, `error_<code>`).
This is the repo's only privilege-adjustment code, so it also carries the ownership rules
any future one should copy:
* The process token lives in a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR
owns it from the moment `OpenProcessToken` returns — the critical finalizer releases it
even if `Dispose` never runs (async exception before the instance reaches its `using`).
* The restore obligation is recorded immediately after the successful `AdjustTokenPrivileges`,
and only when the privilege was not *already* enabled; nothing that can throw sits between
the adjustment and that assignment. `Dispose` is idempotent, swallows a failed restore, and
releases the handle in a `finally`.
* The privilege is adjusted on the **process** token, so enable → read → restore is a
process-wide side effect and runs under a static gate. Without it a concurrent reader would
see "already enabled", correctly skip its own restore, and then lose the privilege mid-read
when the first reader disposes — a spurious `privilege_denied` on a machine that holds it.
Fields land on the existing `secureboot_status` event (once per agent start,
restart-deduped by the StartupEventGate): `uefiFirmwareReadStatus`, per-cert booleans
(`uefiDbHasWindowsUefiCa2023`, `uefiDbHasWindowsProductionPca2011`,
`uefiKekHasMicrosoftKek2kCa2023`, …) — emitted only when the respective read succeeded —
plus the marker below.
## The one-sided marker (absence-tolerant suppression)
Rule-engine constraints (pinned by `RuleEnginePreconditionTests`):
* **Conditions** never match on an absent field (null guard) — new fields must stay
`required: false` or the rule dies for every old agent.
* **Preconditions**: `not_exists` passes while the field is absent/empty and fails as soon
as the field is present with *any* value; comparison operators fail closed on absence.
A two-sided boolean therefore cannot drive suppression: `not_exists` on
`uefiDbHasWindowsUefiCa2023` would also suppress the rule when the field is present with
`false`. Hence the **one-sided marker** `uefiCa2023FirmwareConfirmed = true`, emitted
ONLY when the db read succeeded AND the 2023 certificate was found — never as `false`.
ANALYZE-SEC-001 v3 adds a `not_exists` precondition on it:
| Device state | Marker | Rule |
|---|---|---|
| Old agent (no firmware fields) | absent | evaluates exactly like v2 |
| New agent, cert missing or firmware unreadable | absent | evaluates; optional conditions `uefiDbHasWindowsUefiCa2023=false` (+20) / `uefiKekHasMicrosoftKek2kCa2023=false` (+10) raise confidence |
| New agent, firmware confirms cert | present | silently suppressed (the customer-reported false positive) |
Because the marker exists nowhere until the new agent ships, the v3 rule is safe to seed
**before** the agent rollout.
## Examples
Customer-equivalent verification from PowerShell (authoritative, matches the reader):
```powershell
[Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023')
[Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name KEK).Bytes).Contains('Microsoft Corporation KEK 2K CA 2023')
```
## Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/UefiSecureBootCertReader.cs` — native reader
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — `CollectSecureBootStatus` / `AppendFirmwareCertFields`
* `rules/analyze/ANALYZE-SEC-001.json` — v3.0.0 rule definition
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs` — pinned precondition semantics + v3 mirror tests
* Microsoft: [Act now — Secure Boot certificates expire June 2026](https://techcommunity.microsoft.com/blog/windows-itpro-blog/act-now-secure-boot-certificates-expire-in-june-2026/4426856)
-110
View File
@@ -1,110 +0,0 @@
---
type: Concept
title: System Timeline Watcher — clock steps and sleep episodes from the System event log
description: How the agent turns two enrollment blind spots into timeline ground truth via one EventLogWatcher on the System channel — system_clock_changed (Kernel-General 1, oldTime/newTime/timeDeltaMs/process) and system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate, Kernel-Power 507 Modern Standby) — including backfill of pre-agent records, the payload-times-are-authoritative rule, SleepSpans in time attribution, the web clock-set jump cause, and ANALYZE-DEV-012.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs
tags:
- agent
- eventlog
- time
- clock-skew
- standby
- collector
- time-attribution
- analyze-rules
timestamp: 2026-08-21T00:00:00+02:00
---
# System Timeline Watcher
Two things silently distort every session timeline and its wall-clock duration:
1. **The OS clock gets stepped** — classically w32time correcting a wrong BIOS clock early
in OOBE, but also tzutil/MDM actions. Before this watcher the jump was only *inferred*
(TimeJumpBadge, `clock_skew`/DEV-008); Windows records the ground truth.
2. **The device sleeps mid-enrollment** — the timeline shows an unexplained gap, the
duration looks inflated, and nothing says "the device was simply asleep for 56 minutes".
`SystemTimelineTracker` (hosted by `SystemTimelineWatcherHost`, config flag
`Collectors.EnableSystemTimelineWatcher`, ConfigVersion 39) subscribes to the **System**
channel with one provider-qualified XPath and forwards both facts as events.
# Sources and events
| Source (System channel) | Emitted event | Key payload |
|---|---|---|
| `Microsoft-Windows-Kernel-General` EventID 1 | `system_clock_changed` | `oldTime`, `newTime` (ISO-8601), signed `timeDeltaMs`, `reason`/`reasonText` (1=`application_set`, 2=`hardware_clock_sync`), `processName`/`processId` |
| `Microsoft-Windows-Power-Troubleshooter` EventID 1 | `system_sleep_episode` | `kind`=`sleep`/`hibernate` (EffectiveState 5 ⇒ hibernate), `enteredAt`=SleepTime, `exitedAt`=WakeTime, `durationSeconds`, `wakeSourceType`/`wakeSourceText` |
| `Microsoft-Windows-Kernel-Power` EventID 507 | `system_sleep_episode` | `kind`=`modern_standby`, `exitedAt`=TimeCreated, `enteredAt`=exit − DurationInUs, `durationSeconds` (scenario), `sleepDurationSeconds` (real sleep), `reason`, `onAcPower`, `batteryRemainingCapacityOnExit` |
**506 is deliberately not consumed.** 507 carries the full episode; pairing 506/507 would
need an orphan-tolerant state machine across agent restarts for zero information gain — an
episode without a 507 never completed and would not be emitted under any design.
# Thresholds (constants, not knobs)
- `MinClockDeltaMs = 2000` — w32time logs Kernel-General 1 for routine **1 ms micro-slews**
(verified live, several per hour); only genuine steps are emitted. Suppressed records are
still claimed by the dedup watermark.
- `ClockDeltaWarningMs = 5 min` — larger steps are Warning + immediate upload (can break
token/cert validation windows; matches the web TimeJumpBadge threshold).
- `MinSleepDurationSeconds = 60` — **Modern Standby only**: every screen-off scenario logs a
506/507 pair, including seconds-long ones with `SleepDurationInUs 0` (`SleepEntered=false`);
the single duration gate suppresses both. Classic Power-Troubleshooter episodes have no
floor — they only exist for genuinely completed S3/S4 transitions.
# Payload times are authoritative
The decisive clock correction happens exactly when the device clock is untrustworthy, and
the backend clamps implausible event timestamps (±168 h/24 h) to server time. Every semantic
instant therefore travels as an explicit ISO-8601 payload field (`oldTime`/`newTime`,
`enteredAt`/`exitedAt`); consumers must never read the event's own `Timestamp` for these.
The event `Timestamp` is set to the record's TimeCreated (WindowsUpdateTracker backfill
convention) so rows land where the wake/step happened.
# Backfill and dedup
Template: `WindowsUpdateTracker`. `LoadWatermark → arm watcher → backfill` with lookback
`SystemEventBackfillLookbackMinutes` (default 1440 = 24 h; 0 disables). The System .evtx
persists across OOBE reboots, so the pre-agent w32time correction and pre-agent standby
episodes are recovered with `backfilled=true`. Cross-restart dedup via RecordId watermark
(`system-timeline-watermark.json` in the state directory) plus an intra-run `HashSet` —
deliberately **not** a high-water mark, which would drop exactly the older, never-emitted
pre-agent records (the live watcher arms before the backfill scan).
# Non-activity classification (both sides)
Both event types are environment observation, not enrollment progress:
`SignalActivityClassifier.NonActivityEventTypes` (agent — must not reset idle/stall clocks)
and `EventIngestProcessor.IsPeriodicOrStallEvent` (backend — a device that slept through its
stall must stay Stalled).
# Consumers
- **Web timeline** (`EventTimeline.tsx`): badge blocks for both events; the TimeJumpBadge
gains the ground-truth cause `clock-set` — `classifyTimeJump` receives the session's
`system_clock_changed` deltas and matches a backward display step against a backward clock
set within max(60 s, 20 %) (`lib/timeProvenance.ts`).
- **Duration story** (`SessionInfoCard` via `useSessionDerivedData.standbySeconds`): the
wall-clock duration deliberately keeps the pause (the enrollment really took that long);
a `· 🌙 56m standby` note explains it. Episodes are dedup'd on `enteredAt`.
- **Time attribution** (`TimeAttributionCalculator.BuildSleepSpans`, AttributionVersion 3):
`SleepSpan`s mirror `RebootSpan`s — cross-cutting annotations clipped to the observation
windows (a WhiteGlove-pause episode contributes only its in-window flanks), never a slice
of the wall-clock partition. Persisted as `SleepSeconds`/`SleepSpansJson`; rendered as the
standby chip in `TimeAttributionLane`.
- **ANALYZE-DEV-012** (info, `trigger: single`, `on_event:system_sleep_episode`): fires once
per session for episodes ≥ 5 min. Deliberately the ONLY new rule: clock issues already
have DEV-008 (`clock_skew`) — a second clock rule would double-report the same action item
(user decision 2026-08-21); clock steps stay timeline ground truth with badges instead.
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/SystemTimelineWatcherHost.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` (`BuildSleepSpans`)
- `src/Web/autopilot-monitor-web/lib/timeProvenance.ts` (`clock-set`, `findExplainingClockChange`)
- `rules/analyze/ANALYZE-DEV-012.json`
- Live payload evidence (2026-08-21, Win11 26220): Kernel-General 1 with `TimeDeltaInMs=1`
micro-slews; Kernel-Power 507 with `SleepDurationInUs=2324095807`; Power-Troubleshooter 1
with `SleepTime`/`WakeTime` across a hibernate.
-108
View File
@@ -1,108 +0,0 @@
---
type: concept
title: WDP Scenario Gates & Completion Backstop
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
resource: agent
tags: [wdp, device-preparation, decision-engine, collectors, completion]
timestamp: 2026-08-18
---
# Schema
Windows Device Preparation (Autopilot v2, "WDP") has **no deployment profile and no ESP**.
Before the afee7ae0 audit (2026-08-18) the agent ran the full Classic apparatus anyway:
misleading `autopilot_profile_missing` warnings, a ZTD event-log query + HTTP probe,
`esp_config_detected` "unknown/unknown" noise, an Autopilot-channel noise flood
(EventIDs 100/417/418/1005, 900+ occurrences per session), a subtree registry watcher
woken by every WDP progress write, and — worst — no completion path when Hello never
resolved (park until the 360-min watchdog).
## Detection: deterministic vs. fallback
`EnrollmentRegistryDetector` classifies v2 through three rules. Only the first is
**deterministic WDP**:
1. `AutopilotSettings\DevicePreparation\BootstrapperAgent` subkey with non-empty
`ExecutionContext` → the WDP policy orchestration tree, present before the agent
starts. Exposed as `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`
and the `DevicePreparationExecutionContextRule` constant.
2./3. `CloudAssignedDeviceRegistration == 2` / `CloudAssignedEspEnabled == 0` —
profile-derived legacy signals. They imply a REAL Autopilot profile (just ESP-less),
so every gate below deliberately ignores them: **gates require the deterministic
marker and fail toward Classic behavior.**
## Agent-side gates (all keyed to the deterministic marker)
* `DeviceInfoCollector.CollectAutopilotProfile`: no `autopilot_profile` event, no
`autopilot_profile_missing` warning, no ZTD event-log query, no ZTD HTTP probe.
`enrollment_type_detected` still fires. (WDP devices DO carry an
`AutopilotPolicyCache` with `ProfileAvailable=0` / error 807 `ZtdDeviceIsNotRegistered`
— Windows runs the ZTD download regardless — so the probe fired for real before.)
* `CollectEspConfigurationLocked`: `esp_config_detected` is emitted only when at least
one FirstSync value or ESP tracking list exists (generic null-gate, mirrors the
orchestrator bootstrap's signal gate) — fixes "unknown/unknown" for WDP AND plain
Entra joins.
* `DefaultComponentFactory`: `EspPolicyProviderStallHost` (60-s poll of the ESP-only
`EnrollmentStatusTracking` key) is not created on WDP.
* `EspAndHelloTracker.Start`: `ProvisioningStatusTracker` is not started on WDP — its
subtree watcher sits on `AutopilotSettings`, exactly where WDP's BootstrapperAgent
writes progress, so it woke on every write only to read three ESP category values
that never exist there.
* `ModernDeploymentTracker`: `DevicePreparationNoiseEventIds` {100, 417, 418, 1005}
are suppressed entirely on WDP (before `FormatDescription()`); Critical (level 1)
always passes; genuine WDP diagnostics (e.g. 408) keep flowing. The Autopilot error
backfill (807/809/815/908 replay) is skipped — 807 is the EXPECTED state on WDP.
The watcher itself stays on: WDP's provisioning stack logs into the same channels.
* `StallProbeCollector`: skips the ESP category registry scan on WDP and unions the
noise IDs into its harmless set; the channel scans remain (they carry genuine WDP
diagnostics during a stall).
* `EnrollmentTerminationHandler`: the `app_install_starved` terminal sweep is skipped
on WDP — post-desktop installs are the DESIGNED order there (only DPP-policy apps
run during provisioning); per-app data still travels via `app_tracking_summary` and
final-status.json.
## DecisionCore: WDP completion without ESP facts
* **Seeding**: `EnrollmentFactsObserved` carries `enrollmentTypeDeterministic`;
the deterministic marker seeds `DevicePreparation` at **High** confidence (same
registry-determinism standard as the self-deploying OobeConfig bits), the fallback
rules stay Medium. The three explicit downgrade guards from d0ab3eee remain as
defence in depth.
* **Arm D** (`ShouldTransitionToAwaitingHello`): on WDP the DAD-validated real-user
desktop is a legitimate promotion basis — arms A–C are unsatisfiable by construction
(no provisioning categories, no Shell-Core 62407 final exit). With a disabled Hello
policy the fast-path now completes at desktop arrival.
* **Prerequisites**: `completion_waiting` never lists
`account_setup_provisioning_complete` on WDP (unsatisfiable; mirrors the
`skipUserEsp` exemption).
* **Backstop**: `DeadlineNames.DevicePrepCompletion` (30 min, replay-safe base) is
armed by `HandleDesktopArrivedV1` when a WDP session parks desktop-first with Hello
pending. On fire it mirrors the HelloSafety timeout: synthetic
`HelloOutcome=Timeout` + completion through Finalizing. This closes the
"no Hello → park until watchdog" hole; a timer surviving into a terminal stage is
swallowed by the post-terminal dispatch guard.
* **Tripwire**: `DecisionStepProcessor.IsParkedWithoutDeadline` has a WDP branch
(dead-end zone = desktop arrived) so a regression in the backstop becomes visible
as `session_parked_without_deadline`.
# Examples
Session afee7ae0 (the audit trigger): Succeeded in 9:32 min, but its timeline carried
`autopilot_profile` (807), an `autopilot_profile_missing` WARN, `esp_config_detected`
unknown/unknown, 30 `modern_deployment_log` rollup events and 10 `app_install_starved`
entries — all structurally meaningless on WDP. After the gates, the same session shape
emits none of these; completion rode Hello-skipped + desktop as before.
Deliberately NOT changed (open follow-ups, see the WDP audit topic memory): the ESP
phase vocabulary (`esp_phase_changed`/stage names ride the Classic rails on WDP),
subscription to the `BootstrapperAgentServiceLogProvider` event channel, and
`registry_app_state` as the primary WDP app source.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs` — deterministic rule + helper
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — profile/ESP emission gates
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs` — `DevicePreparationNoiseEventIds`
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs` — backstop deadline
* `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs` — High/Medium seeding
* Tests: `DevicePreparationEngineTests`, `ModernDeploymentTrackerDevicePrepTests`, `EnrollmentTerminationHandlerTests.StarvedApps_skipped_on_device_preparation`, `DeviceInfoCollectorEspRefreshTests.NoEspEvidenceAtAll_SuppressesTheEvent`
-185
View File
@@ -1,185 +0,0 @@
---
type: Concept
title: WiFi SSID and the Windows 24H2 Location Gate
description: Why wifi_signal_info silently disappeared on Windows 11 24H2+ devices, why the netsh fallback could never fix it, and the tiered reader that restores the SSID from the SYSTEM context.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs
tags:
- agent
- interop
- wifi
- telemetry
timestamp: 2026-08-26T14:05:00+02:00
---
# Summary
The agent reports the enrollment WiFi as a `wifi_signal_info` event (SSID, signal, PHY, channel).
On Windows 11 24H2 and later the event went missing on a large share of devices — no error, no
partial payload, simply no event. The cause is a Windows privacy change, not a bug in the reader:
**`WlanQueryInterface` with `wlan_intf_opcode_current_connection` is gated behind precise-location
consent**, and the agent runs as SYSTEM in session 0 where that consent usually does not exist.
The fix is a tiered reader ([WifiInfoProvider](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs))
whose middle tier uses the one SSID API Microsoft left ungated.
# The Gate
Microsoft restricted the Win32 WLAN APIs that expose BSSIDs, because nearby-BSSID lists are a
precise-location oracle. Without consent these return `ERROR_ACCESS_DENIED` (5):
* `WlanGetAvailableNetworkList`
* `WlanGetNetworkBssList`
* `WlanScan`
* **`WlanQueryInterface` when `OpCode` is `wlan_intf_opcode_current_connection`** — the only opcode
the agent needs, and the one that carries SSID, signal quality, PHY type.
Two properties of the gate decide the agent's fate:
1. **The consent prompt only appears for a process running in the user's context and outside
`C:\Windows\System32`.** The agent is neither during ESP: it is SYSTEM, in session 0, before any
user has seen the OOBE privacy page. It is therefore denied *silently* — no prompt, no event
log, nothing that surfaces without asking the API for its return code.
2. **Consent resolves per user hive, with the device-level setting as the floor.** An interactive
user typically has
`HKCU\...\CapabilityAccessManager\ConsentStore\location\NonPackaged\Value = Allow`
("Let desktop apps access your location"). The SYSTEM account (`HKU\S-1-5-18`) normally has
**nothing** set there and falls back to
`HKLM\...\ConsentStore\location\Value`. Where that device-level value is `Deny`, SYSTEM is
denied even though the logged-on user on the very same machine reads the SSID fine.
That asymmetry is why the failure looks random from the outside: it tracks the **image's
device-level location setting**, which varies per fleet, not per hardware, driver, or language.
# Why netsh Was Never the Answer
`NetshWifiFallback` was added in 2026-07 on the theory that inbox `netsh` still reported the
connection where the WLAN API did not, and it was later extended with localized label variants for
every enrollment-geography language. Field data says otherwise: **`netsh wlan` hangs off the same
location gate** ("Network shell commands need location permission to access WLAN information"), so
it fails in lockstep with tier 1 rather than covering for it. In the reported session the agent
build already contained the netsh fallback and still produced no event.
Consequence for future work: **adding more netsh language labels cannot fix a missing SSID.** Every
`wifi_signal_info` event that does exist carries a fully populated SSID — the failure is binary at
the reader level, never a half-parsed or mis-decoded payload. A missing SSID is an access problem,
not a parsing problem.
# The Tiered Reader
`WifiInfoProvider.TryRead` owns the order so the two collectors (`DeviceInfoCollector`,
`NetworkChangeDetector`) cannot drift apart. First non-null wins:
| # | Reader | Yields | Location-gated |
|---|---|---|---|
| 1 | `WifiInfoReader` (native `wlanapi.dll`) | SSID, signal %, PHY, channel | yes |
| 2 | `WinRtWifiSsidReader` (WinRT `GetConnectedSsid`) | SSID only | **no** |
| 3 | `NetshWifiFallback` (process spawn, localized parse) | SSID, signal %, radio type, channel | yes |
Tier 2 is Microsoft's own documented replacement for this exact scenario:
`Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid()`. It is not part of
the BSSID-bearing surface, so it needs no consent, and it has existed since Windows 10 1507.
It is bound through the CLR's built-in WinRT projection with
`Type.GetType("…, ContentType=WindowsRuntime")` — no NuGet package, no winmd reference, no csproj
change — the same mechanism [OobeStateReader](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs)
already uses from the SYSTEM service context. Every step degrades to null rather than throwing,
because the caller runs fire-and-forget inside `Task.Run`.
# Telling the Operator
A missing 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. When tier 2 wins
*and* the native tier reported `rc=5`, the `wifi_signal_info` payload therefore carries
```
wifiDataLimitedReason = "location_services_off"
```
and the event message gains `- signal unavailable, Location services off`, so the reason is visible
in the timeline itself. The portal's WiFi card (`DeviceDetailsCard`) matches that literal and
renders the explanation plus the Intune setting that fixes it. The value is a published contract —
agent, portal and the MCP device-property catalog all match the same string.
The claim is only made on observed evidence: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting we did not
measure. `WifiInfoProvider.IsLocationGateDenied` is the single predicate, pinned by tests at its
boundaries (`rc=5` yes; `rc=50`, `rc=5023`, `rc=1062` no).
The network timeline reads the same field. It needs no payload of its own: `buildSegments`
already merges every `wifi_signal_info` into the WiFi segment covering its timestamp, so the
reason rides in with the sample. A segment that never got a sample (a mid-session reconnect emits
no `wifi_signal_info`) inherits it — the gate is a device-level setting and cannot flip during an
enrollment — while a segment that *did* get a real reading is never overwritten. The segment
tooltip then reads `Signal: unavailable - Location services off on this device` where the
percentage would be, and the "Avg WiFi signal" stat says `n/a (Location services off)` instead of
disappearing.
`network_state_change` deliberately does NOT carry the reason: that event has never carried
`signalPercent` or `radioType` at all (`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. Adding it would ship a field nothing reads. The SSID it does
carry is unaffected by the gate.
Fleet-wide the same field is queryable through
`search_sessions(deviceProperties: { "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })`,
which sizes how much of a fleet is affected before anyone changes a policy.
Tier 2 returns the SSID alone. Signal quality, PHY type and channel have no ungated source; they
stay null when the native tier is denied. That is deliberate — deriving a percentage from
`ConnectionProfile.GetSignalBars()` (0–5) would fabricate precision the API does not have.
# Diagnostics
The step-level native return codes were already collected, but they were logged with
`_logger.Debug` — and the agent's default log level is `Info`, so the diagnostic built to explain
this failure never reached a single field log. Both collectors now log at **Warning** when all tiers
fail, which they only reach when the active NIC really is WiFi. A native `rc=5` is spelled out as
`ERROR_ACCESS_DENIED` with the location-gate explanation, so the log line diagnoses itself:
```
EnrollmentTracker: no wifi_signal_info — no WiFi info from any reader — native: [<guid>]
query(current_connection) rc=5; ; WinRT: no SSID; netsh: no parsable output. rc=5 is
ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection opcode (and netsh wlan)
behind precise-location consent, which is off in this image; enable Location services to
restore signal/PHY/channel.
```
# Evidence
Session `7305e0ad` (HP EliteBook X Flip G1i, build 26200.9168, agent 2.0.1407, reported as
`WIFI SSID fehlt`): `network_interface_info` shows the active NIC as `Intel(R) Wi-Fi 7 BE201`,
`Wireless80211`, `connectionType=WiFi`, so the `isWifi` gate passed and collection ran — yet zero
`wifi_signal_info` events across 214 events and 22 minutes, with the netsh fallback already shipped
in that build.
Fleet correlation over WiFi sessions from 2026-08-01 (n=276), share with a `wifi_signal_info` event:
| OS build | WiFi sessions | with SSID |
|---|---|---|
| 22631 (23H2, pre-gate) | 3 | 100% |
| 26100 (24H2) | 178 | 21% |
| 26200 (25H2) | 95 | 45% |
Within a single model the outcome splits by fleet, not by hardware — ThinkPad T14 Gen 6: 7/91;
ThinkPad E14 Gen 7: 9/9 — which is what a per-image privacy setting looks like.
Local verification (build 26220.9022): `HKLM\…\ConsentStore\location = Deny` while
`HKCU\…\ConsentStore\location\NonPackaged = Allow`; the interactive user reads the SSID through
both the native API and netsh, `HKU\S-1-5-18` has no consent value at all, and the WinRT chain
resolves and returns the SSID under net48.
# Operator Guidance
A device that reports no SSID is not misconfigured for enrollment — only the telemetry field is
missing. To get the full payload (signal, PHY, channel) back, the **device-level** location setting
must be on: *Settings → Privacy & security → Location → Location services*, or via Intune
(`Privacy/LetAppsAccessLocation`). Without it the SSID still arrives via tier 2.
# Citations
* [Changes to API behavior for Wi-Fi access and location](https://learn.microsoft.com/en-us/windows/win32/nativewifi/wi-fi-access-location-changes)
* [WlanConnectionProfileDetails.GetConnectedSsid](https://learn.microsoft.com/en-us/uwp/api/windows.networking.connectivity.wlanconnectionprofiledetails.getconnectedssid)
* [WifiInfoProvider.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs)
* [WinRtWifiSsidReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WinRtWifiSsidReader.cs)
* [WifiInfoReader.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs)
* [NetshWifiFallback.cs](../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/NetshWifiFallback.cs)
@@ -1,136 +0,0 @@
---
type: Concept
title: Zero User-Targeted Apps — the starved user-phase completion gate
description: Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate, how that turned into a 30-minute esp_exit_without_completion_evidence false positive on a third of one tenant's enrollments, and the two fixes — reading the line IME actually writes in its zero-app branch, and refusing to fail a session while a completion gate is deliberately holding it.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
tags:
- agent
- ime
- esp
- completion
- realmjoin
- false-positive
timestamp: 2026-08-25
---
# Summary
A Classic user-driven enrollment whose user has **zero targeted Intune Win32 apps** could not
produce the evidence every user-phase completion path required. The session parked, and the
30-minute `advisory_completion` backstop resolved it as
`enrollment_failed: esp_exit_without_completion_evidence` — while the user was at a working
desktop, Windows Hello was provisioned, and RealmJoin was still installing packages.
One tenant hit this on **10 of 30 enrollments in a week**. Every one of them was a false
positive; the devices were healthy in Intune and RealmJoin.
# The starvation chain
The IME writes `Completed user session N, userId: …` at the end of a user-session processing
pass. `IME-USER-SESSION-COMPLETED` matches it and the adapter posts
`ImeUserSessionCompleted`, which sets `DecisionState.ImeUserSessionCompletedUtc`.
But the IME never reaches that statement when the user has no assigned apps. Decompiled IME
`1.104.102.0`, `Win32AppPlugIn/ApplicationPoller.cs`:
```csharp
if (!appPolicies.Any())
{
AppWorkloadLog.TraceInformation($"[Win32App] Get 0 apps for user session {user.RDSSessionId}, user id = {user.IntuneId}");
RegisterApplicationsInIfInESP(sender, user, delegate {
espLockInProcessor.AttemptToLockInApplications(user, new List<ProcessorSubgraph>());
});
continue; // skips the "Completed user session" statement below
}
```
Identical shape in `1.97.107.0`, `1.83.107.0`, `1.80.132.0`. With that fact missing, every door
out of the user phase is shut at once:
| Path | Requirement | Why it is dead |
|---|---|---|
| `ShouldTransitionToAwaitingHello` arm A | `AccountSetupProvisioningSucceededUtc` | ESP registry froze at `AccountSetup: 1 of 5`; neither `categorySucceeded` nor the all-subcategories fallback can ever hold |
| arm B | `SkipUserEsp` | Full ESP, not a skip-user flow |
| arm C | `IsImeUserSessionGenuine` | needs the missing IME fact |
| arm D | `EnrollmentMode.DevicePreparation` | Autopilot v1, not WDP |
| `MaybeSynthesizeAccountSetupCompleteFromSettledUserApps` | `AreUserEspAppsSettled` | returned `false` on an empty app list |
| `HandleAdvisoryCompletionDeadlineFired` conjunction | `imeUserSessionGenuine \|\| isObservationAdvisory` | both false |
The last row is the one that produced the verdict: the backstop that exists to resolve a
dead-end resolved it as a failure.
`AreUserEspAppsSettled` deserves its own note. Its "at least one required app" rule is
deliberately conservative — an empty list normally means *the phase just cleared* or *apps have
not surfaced yet*. But it made **zero apps**, the most settled state possible, indistinguishable
from *unknown*, and there was no evidence available to tell them apart.
# Fix 1 — read the line IME does write
`IME-USER-SESSION-ZERO-APPS` matches the zero-app line and routes it through the **same**
`OnUserSessionCompleted` callback. Downstream this genuinely *is* a completed user session, so
the adapter's pending-apps deferral and its fire-once flag apply unchanged.
This is not a weakened gate — it is IME's own verdict. The zero-app branch locks the ESP in with
an *empty* application list, and the sibling emit site in `AppWorkloadAbstraction.ExecuteUserCheckIn`
returns `ProviderResult.ProvisioningComplete` straight after logging it. All three emit sites are
user-session-scoped (`user.RDSSessionId`, `user.IntuneId`); none is device-scoped.
Guards that stay in force:
* **Phase guard** — the observation only fires while the tracker is in `AccountSetup`. Firing
earlier would burn the adapter's fire-once flag on a pre-sign-in (defaultuser0) timestamp that
`IsImeUserSessionGenuine` (`>= AccountSetupEnteredUtc`) can never accept. An observation made
before the phase was known is remembered and replayed on the transition into it, because IME
writes its check-in lines independently of when the agent first parses the phase marker.
* **Engine guards unchanged** — arm C still requires a post-AccountSetup final ESP exit *and* a
DAD-validated real-user desktop.
* `AreUserEspAppsSettled` returns `true` for an empty list **only** with this explicit
observation — never for a merely empty list.
# Fix 2 — do not fail while a completion gate is holding
An independent defect, and the decisive one for the affected tenant. `advisory_completion` runs
for 30 minutes. The RealmJoin completion gate blocks completion until phase 110 and carries its
*own* bounded resolution: 60 minutes from detection, re-armed on deployment activity, hard-capped
at 4 hours. The shorter timer always won.
Session `75d6ae8e` installed **25 RealmJoin packages successfully inside the window**, the last
one 2 minutes before the failure, while the engine's own `completion_waiting` still read
`waiting on: realmjoin_resolution`.
`HandleAdvisoryCompletionDeadlineFired` now re-arms instead of failing when the RealmJoin gate is
closed *and* its deadline is still armed, emitting `completion_waiting` with trigger
`DeadlineFired:advisory_completion:CompletionGateHolding`.
Convergence comes from the gate itself: `Resolved`, `FirstDeploymentIncomplete` and `Timeout` all
set `RealmJoinFacts.Outcome`, which opens `RealmJoinGateOpen` and stops the guard applying.
Requiring the gate's deadline to still be armed keeps a gate that can no longer resolve itself
from parking the session; `AgentMaxLifetimeMinutes` bounds it regardless.
# Timeline evidence
`ime_user_session_completed` carries an `evidence` field so the basis of the verdict is legible
without the diagnostics archive:
* `user_session_completed` — IME wrote its completion line and no required app was pending.
* `zero_user_apps_enumerated` — IME reported zero targeted apps; the user phase had nothing to
enforce.
No new event type: the fact is the same, only the observation behind it differs.
# Deployment
The pattern ships through the config API and reaches existing agents without a rebuild; an agent
that does not know the `userSessionZeroApps` action logs one Debug line and ignores it. The
*behaviour* needs an agent release. Sessions already stamped `Failed` are not reclassified — the
backend mirrors the agent verdict (`VerdictPaths.AgentFailed`) and has no independent judgement
here by design.
# Citations
* `rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.json`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs` — `HandleUserSessionZeroApps`, the phase replay in `HandleEspPhaseDetected`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs` — `AreUserEspAppsSettled`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs` — `EmitUserSessionCompleted`, evidence labels
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — `HandleAdvisoryCompletionDeadlineFired`
* [decision-engine.md](decision-engine.md), [post-reboot-completion-recovery.md](post-reboot-completion-recovery.md)
-135
View File
@@ -1,135 +0,0 @@
---
type: Architecture Guide
title: Autopilot Monitor – Architecture Guide
description: High-level architecture overview and solution layout for contributors.
tags:
- architecture
- agent
- backend
- web
- mcp
timestamp: 2026-07-06T21:39:29+02:00
---
# Autopilot Monitor – Architecture Guide
> High-level architecture overview and solution layout for contributors.
> Full product documentation lives at https://docs.autopilotmonitor.com.
---
## High-Level Overview
```text
+----------------------------------------------------------------------------------+
| Autopilot Monitor - Architecture |
+----------------------------------------------------------------------------------+
[Admin/User Browser]
|
| HTTPS
v
+-----------------------------+ OIDC +-------------------------------+
| Web App (Next.js) |<---------------->| Microsoft Identity (Entra ID) |
| Dashboard / Progress | | Auth / Authorization |
+-----------------------------+ +-------------------------------+
^ ^
| |
| | Live updates
| |
| | +---------------------------+
| +-------------------------| SignalR Hub |
| | Real-time event stream |
| +---------------------------+
| ^
| REST / API |
v | events
+---------------------------+ Data & |
| Azure Functions Backend |------> Integrations ---+
| APIs / Ingestion / Rules | \
+---------------------------+ \ +---------------------------+
^ +----->| Storage Tables |
| mTLS | Sessions / Events / Rules |
| telemetry / events +---------------------------+
| +---------------------------+
+---------------------------+ | Notifications |
| Agent on Windows Devices | | Teams / Slack / Webhooks |
| Enrollment + app progress | +---------------------------+
+---------------------------+
+---------------------------+
| MCP Server |
| Azure Container App |
+---------------------------+
```
**Core Data Flow:**
```
Device (Agent) ──JSON telemetry+gzip──► Azure Functions ──► Azure Table Storage
│ │
├── SignalR ─────────►│
│ │
Web Dashboard ◄── REST API ◄──┘
```
| Component | Technology | Purpose |
|-----------|------------|---------|
| **Agent** | .NET Framework 4.8 (Windows) | Runs on devices during enrollment, collects telemetry |
| **SummaryDialog** | .NET Framework 4.8 (WPF) | Post-enrollment summary UI (outcome, app timeline, auto-close) |
| **Backend** | Azure Functions .NET 8 (Isolated Worker) | REST API, event processing, rule engine, storage |
| **Web** | Next.js 15 + TypeScript + React 18 | Dashboard, settings, analytics, real-time UI |
| **Shared** | .NET Standard 2.0 | DTOs, models, enums, constants shared between Agent & Backend |
| **MCP Server** | Azure Container App | Model Context Protocol server for AI-assisted analysis |
---
## Solution Structure
```
AutopilotMonitor.sln
├── src/
│ ├── Shared/
│ │ ├── AutopilotMonitor.Shared/ (netstandard2.0 – DTOs, models, enums, constants)
│ │ └── AutopilotMonitor.DecisionCore/ (netstandard2.0 – completion decision engine)
│ ├── Agent/
│ │ ├── AutopilotMonitor.Agent.V2/ (net48, Exe – entry point; output named AutopilotMonitor.Agent.exe)
│ │ ├── AutopilotMonitor.Agent.V2.Core/ (net48, Lib – core logic)
│ │ ├── AutopilotMonitor.Agent.V2.Core.Tests/ (net48, xUnit – agent unit tests)
│ │ ├── AutopilotMonitor.DecisionCore.Tests/ (net48, xUnit – decision engine tests)
│ │ ├── AutopilotMonitor.SummaryDialog/ (net48, WPF – enrollment summary UI)
│ │ └── AutopilotMonitor.SummaryDialog.Tests/ (net48, xUnit)
│ ├── Backend/
│ │ ├── AutopilotMonitor.Functions/ (net8.0, Azure Functions v4)
│ │ └── AutopilotMonitor.Functions.Tests/ (net8.0, xUnit)
│ ├── McpServer/
│ │ └── autopilot-monitor-mcp/ (TypeScript/Node – MCP server; deployed as Azure Container App)
│ └── Web/
│ └── autopilot-monitor-web/ (Next.js 15, TypeScript)
├── rules/
│ ├── gather/ Individual gather rule JSONs
│ ├── analyze/ Individual analyze rule JSONs
│ ├── ime-log-patterns/ IME regex pattern JSONs
│ ├── schema/ JSON Schema definitions
│ ├── guardrails.json Gather-rule guardrail allow-lists (source)
│ ├── scripts/ combine.js
│ └── dist/ Combined output (embedded in Functions)
├── infra/ Bicep templates (MCP server)
├── scripts/
│ ├── Backup/ Table backup/restore tooling
│ ├── Bootstrap/ Intune deployment scripts
│ ├── CustomerSetup/ Customer onboarding helpers
│ └── Deployment/ CI/CD build scripts
└── .github/workflows/ CI/CD pipelines
```
**Project References:**
```
Shared + DecisionCore ◄── Agent.V2.Core ◄── Agent.V2
◄── Agent.V2.Core.Tests
Shared + DecisionCore ◄── DecisionCore.Tests
Shared + DecisionCore ◄── Functions ◄── Functions.Tests
SummaryDialog ◄── SummaryDialog.Tests (standalone; compiled alongside the agent build)
Web (independent – communicates via REST + SignalR)
McpServer (independent – talks to the backend REST API)
```
---
-151
View File
@@ -1,151 +0,0 @@
---
type: Concept
title: Admin Identity Binding — tid + oid behind every cross-tenant-role UPN
description: Why a GlobalAdmins or DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in both role services, the first-sign-in object-id pin, and the explicit rebind surface.
resource: src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
tags:
- backend
- security
- authorization
- multi-tenant
- global-admin
- delegated-admin
timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
The API is a multi-tenant Entra application on purpose: `AuthenticationMiddleware` accepts a token from
any tenant (issuer prefix `login.microsoftonline.com/` or `sts.windows.net/`), and signature, audience,
lifetime and algorithm are validated strictly. Tenant-member roles (`TenantAdmins`) are keyed on
`(tid, upn)` and therefore bound to the verified tenant.
The two cross-tenant tiers were not. `GlobalAdmins` (GlobalAdmin / GlobalReader) and the delegated MSP
tier (`DelegatedAdmins` + `TenantGroupAssignments`) resolved their role from the **UPN string alone**
(`upn`, falling back to `preferred_username`). Both claims are mutable and reusable across tenants:
* **Domain re-registration**: if the domain behind a platform admin's UPN lapses or moves, whoever
verifies it in a fresh tenant can mint the identical UPN; the token is genuine, only `tid` differs.
* **UPN recycling**: a User Administrator in the home tenant can re-assign the UPN to another account;
same `tid`, different `oid`.
* **MSP variant**: the delegated tier was gated only on the caller's home tenant being Pro — which any
tenant can self-provision via the trial endpoint.
Any of these yielded full platform scope: raw table dumps including `TenantConfiguration` secrets,
tenant offboarding, GA management. Microsoft's own guidance (post-nOAuth) is explicit: authorization
data must be keyed on the immutable `tid` + `oid` pair, never on `upn` / `preferred_username`.
# Mechanism
## One binding per UPN
`AdminIdentityBindings` (PK `Bindings`, RK = lowercase UPN) holds, for every UPN that carries a
cross-tenant role, the identity that may use it:
| Column | Meaning |
| --- | --- |
| `TenantId` | The admin's **home** Entra tenant — must equal the JWT `tid`. Mandatory at grant time. |
| `ObjectId` | The admin's Entra object id — must equal the JWT `oid`. Empty until pinned. |
| `BoundBy`, `BoundDate` | Operator provenance. |
| `ObjectIdPinnedDate` | When the object id was fixed (grant time, or the first matching sign-in). |
A single table rather than columns on three role tables: a UPN string belongs to exactly one identity
at a time, a person may hold a GlobalAdmins row *and* delegated assignments, and the role rows stay
what they are — grants. The binding says whose grants they are.
## Resolution order (both role services)
`AdminIdentity` is the record `(Upn, TenantId, ObjectId)` built from a validated principal; it is
`null` when any of the three claims is missing (app-only tokens, foreign IdP shapes). Both
`GlobalAdminService.GetGlobalRoleAsync(AdminIdentity?)` and
`DelegatedAdminService.GetScopeAsync(AdminIdentity?)` follow the same order:
1. `null` identity → no role, no storage read.
2. Resolve the **row** by UPN (cached 30 s, unchanged from before).
3. No row / empty scope → return; the binding is not even read. Ordinary tenant users cost one cached
read and never produce a binding log line.
4. `AdminIdentityBindingService.IsBoundAsync(identity)` → false ⇒ no role / empty scope.
5. Delegated only: the Pro home-tenant entitlement gate, **after** the binding check — an unbound caller
never triggers an edition lookup.
`IsBoundAsync` is the single choke-point: no binding ⇒ false; `TenantId` mismatch ⇒ false (checked
before anything else, so a foreign-tenant token can never claim the pin below); pinned `ObjectId`
mismatch ⇒ false; unpinned binding + matching tenant ⇒ **pin** the caller's `oid` and verify against
what was actually stored afterwards. Every false outcome is logged at Warning with its reason
(`[IdentityBinding] TENANT MISMATCH …` / `OBJECT-ID MISMATCH …` / `holds no identity binding`), the
one level the worker forwards to Application Insights; matches are silent. Pins are logged at Warning
once — they are one-off, operator-relevant events.
The pin is a conditional update in the repository (`TryPinIdentityObjectIdAsync`: only onto an unpinned
row homed in the caller's tenant, ETag-guarded; a 412 re-reads and the caller compares against the
winner). Two accounts racing the first sign-in inside the home tenant therefore leave exactly one bound.
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
## Grants bind first
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` take
`homeTenantId` (GUID, required) and `objectId` (GUID, optional) and call `EnsureBoundAsync` **before**
writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
supplied object id equals the pin or upgrades an unpinned one), and throws
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
functions map that to **409**. A grant can therefore never silently re-home a UPN.
Why the object id may be omitted: the operator granting an external MSP admin has no directory access
to the partner tenant, so the person's `oid` is frequently unknown at grant time; the home tenant is
always known (it is who the grant is for). The first sign-in from that tenant pins it; from then on a
recycled UPN in the same tenant is refused.
## Explicit rebind surface
| Route | Policy | Purpose |
| --- | --- | --- |
| `GET global/identity-bindings` | GlobalReadOrAdmin | Audit which tenant / object id each grant is usable from. |
| `PUT global/identity-bindings/{upn}` | GlobalAdminOnly | Replace the binding: move a UPN to another tenant, or re-pin after a legitimate account re-creation (omit `objectId` to clear the pin). |
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
values in the details) and logged at Warning. The list endpoints of delegated admins and tenant groups
return `bindings` alongside their rows so the management UI can show a per-UPN context pill (home tenant,
pin state) and require the home tenant in its grant / assign forms.
## Lifecycle and secondary effects
* `AdminIdentityBindings` is in `TableNames.All`, in `CriticalBackupTables.All` and in
`AuthTablesFullRestoreForbidden` (single-row restore only, like the other auth tables).
* Tenant offboarding wipes bindings whose `TenantId` is the offboarded tenant (property-only bucket):
once a home tenant is gone, its admins' grants become inert.
* `RequestContext.ObjectId` carries the JWT `oid`; `AdminIdentity.FromRequestContext` serves the
handlers that resolve roles outside the middleware (SignalR group join/leave, health checks).
* The throttle identity (`GetCallerId`) now prefers `oid` over the UPN, so two accounts in different
tenants that share a UPN string no longer share a rate-limit bucket. Audit and presence rows keep the
UPN as their display key; they already carry the tenant id.
* Legacy role rows without a binding are **inert, not grandfathered**. Rolling this out therefore means
seeding the binding rows for the existing admin UPNs (their `tid` and `oid` are recorded per login in
`UserActivity`) *before* the backend deploy — an unseeded platform admin locks themselves out of every
GlobalAdminOnly route, including the rebind surface.
# Examples
A GlobalAdmin row for `admin@vendor.example` bound to tenant `1111…` with object id `aaaa…`:
| Token | Result |
| --- | --- |
| `tid=1111… oid=aaaa… upn=admin@vendor.example` | GlobalAdmin |
| `tid=2222… oid=aaaa… upn=admin@vendor.example` | no role — `TENANT MISMATCH` warning |
| `tid=1111… oid=bbbb… upn=admin@vendor.example` | no role — `OBJECT-ID MISMATCH` warning |
| `tid=1111… upn=admin@vendor.example` (no `oid`) | no role — identity incomplete |
Same row, binding without an object id: the first `tid=1111…` sign-in pins its `oid` and is admitted; a
`tid=2222…` sign-in before that is refused **and does not pin**.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
* `src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingServiceTests.cs`, `AdminIdentityBindingAuthorizationTests.cs` — verdict matrix, race semantics, and the middleware-level denials for foreign-tenant and recycled-UPN tokens.
* [Client-Certificate Tenant Binding](client-cert-tenant-binding.md) — the same "chain-valid is not tenant-bound" argument on the device side.
@@ -1,82 +0,0 @@
---
type: concept
title: Attempt-Scoped App Install Durations
description: AppInstallSummary.DurationSeconds measures the status-defining (last) install attempt via a LastAttemptStartedAt max-fold anchor — replacing the first-observation-to-last-terminal span that billed multi-pass IME processing gaps to trivial apps; time attribution counts the union of active attempt segments.
resource: src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs
tags: [backend, agent, ingest, app-installs, metrics, time-attribution, durations]
timestamp: 2026-08-21
---
# Problem
The IME processes the ESP app list in multiple passes: a device-ESP evaluation pass can
touch an app (often ending `Skipped`) long before the real install runs in a later pass.
`AppInstallSummary` is keyed by `{SessionId}_{AppName}` and folded from ALL of the app's
events, so `DurationSeconds = last terminal − first observation` spanned those passes.
Field case (session `44862cd3`, "Set TimeZone"): evaluation pass 09:16, real install
10:22:58 → 10:23:21 — reported 4002 s for a 23-second install. Fleet averages, the
Slowest-Apps ranking, p95s, and the version-regression radar all inherited the inflation;
the rule engine's `app_install_duration` (already attempt-scoped) contradicted the
dashboard for the same install.
# Schema
Three durable anchors on the row — each with a distinct, non-interchangeable job:
| Column | Fold | Role |
|---|---|---|
| `StartedAt` | min (earliest wins) | First observation. Server-side window/bucket filter of every AppInstallSummaries scan and the radar's version-ordering key. Never re-anchored. |
| `LastAttemptStartedAt` | **max** (in-batch and in `ReconcileAppInstallSummaryWithExisting`) | Anchor of `DurationSeconds`. Null = sentinel (row predates 2026-08, or no start observed) → duration falls back to the historical `StartedAt` span. Replay of an older batch can never regress it. |
| `InstallPassCount` | additive, guarded by `LastAttemptStartedAt` monotonicity (a batch whose newest start is not newer than the stored anchor adds 0) | Explains "finished long after first seen" (web ×N chip); 2+ is normal. |
`DurationSeconds = CompletedAt − LastAttemptStartedAt`, computed at the terminal event and
re-derived in one central reconcile site covering in-order and Q4 out-of-order batches.
A `LastAttemptStartedAt` newer than `CompletedAt` means a fresh pass is in flight — the
completed attempt's stored duration stands (the 0 value omits the column; Merge preserves).
`DownloadDurationSeconds` pairs the latest `app_download_started` with the NEXT
`app_install_started` (attempt-scoped, in-batch). The full span stays derivable as
`CompletedAt − StartedAt` — deliberately not a column.
Weaker-terminal guard (both in-batch and cross-batch): `app_install_completed` with
state `Skipped`/`Postponed` never overrides an `Installed`/`Error` terminal, nor its
`CompletedAt`/`DurationSeconds` — mirroring the long-pinned `app_install_skipped` rule.
# Consumers
* **Fleet metrics / apps dashboard / PDF** (`MetricsMath`, `AppsAnalyticsHelper`): values
drop to attempt scale; labels say "install time (final attempt)". The 6 h
`MaxPlausibleInstallDurationSeconds` gate remains (an attempt longer than the agent's
lifetime is still unobservable).
* **AppVersionRegressionRadar**: cutover gate in `MeasuredAppGroups` — only rows with
`LastAttemptStartedAt` enter the duration population, on BOTH comparison sides. A mixed
population would mask real regressions and falsely re-arm active episodes for up to the
35-day horizon. `MinMeasuredInstalls` keeps it silent until enough post-cutover rows
exist. Thresholds (2.0× / 300 s) were tuned on span data — re-evaluate ~4 weeks after
rollout.
* **Time attribution** (`TimeAttributionCalculator.BuildBlockingAppIntervals`):
sequence-ordered start→terminal pairing yields one segment per pass/attempt. Per-app
seconds = SUM of clamped segments (failed attempts and evaluation passes count — they
occupied the path); occupancy merges the segments, never the per-app hulls; hull
endpoints remain first-start→last-terminal for display and what-if. Deliberately
BROADER than the metrics duration: active time across all passes vs. final attempt.
* **Agent payloads** (`AppInstallTiming` / `ImeLogTrackerAdapter.UpdateAppTiming`): a
terminal→active transition re-arms BOTH endpoints, so event `durationSeconds` also
describes the attempt (needs the agent release carrying this change).
* **Rule engine** `app_install_duration`: was already attempt-scoped — now consistent.
# Rollout
No backfill (user decision, analog to the CMTrace legacy): pre-cutover rows keep span
values, so time-series charts step down visibly at the changeover (documented in the
public platform changelog). Sentinel-gated columns follow the standard chain:
`BuildAppInstallSummaryEntity` + `MapToAppInstallSummary` + `AppsDashboardProjection`
(the only projection whose consumers read the new columns).
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs` — fold + weaker-terminal guard + attempt pairing
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs` — reconcile max-fold, pass-count guard, central duration recompute
* `src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs` — cutover gate
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — per-pass segments + occupancy
* `src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs` — payload timing re-arm
* Tests: `AppInstallTerminalStateAndReconcileTests`, `EventTimestampValidationTests`, `AppVersionRegressionRadarTests`, `TimeAttributionCalculatorTests`
@@ -1,98 +0,0 @@
---
type: Concept
title: App Version Duration Regression Radar
description: How the radar detects app versions whose median install duration regressed vs. the previous version (median lift ≥2x plus an absolute floor over first-seen-ordered versions), how alert episodes live in the appversionregression tracker keyspace, and which surfaces show them.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs
tags:
- backend
- apps
- regression-radar
- insights
timestamp: 2026-08-13T12:00:00+02:00
---
# App Version Duration Regression Radar
Detects "version X made this app's installs much slower" — the concrete, quotable insight
("median install duration rose from 11 to 29 min after version X") — from the per-(session,
app) `AppInstallSummaries` rows. Duration only: failure-rate per version is already visible
in the apps dashboard's `versionBreakdown`, and rate regressions belong to Wilson-interval
statistics, not medians.
# Schema
## Signal & detection
Per (tenant, app), the MEASURED install population is: `Status == "Succeeded"`, terminal
state not Skipped/Postponed, `MetricsMath.HasMeasuredDuration` (1s..6h — 0s means the start
was unobserved, above 6h means back-stamped batches), no `AppIdCollision`, non-empty
`AppVersion`. Versions are ordered by FIRST-SEEN install time (min `StartedAt`), never by
string comparison ("9.1" vs "2024.10" would lie). The comparison pair is always the newest
version (the one with the most recent measured install) vs. the version with the latest
first-seen strictly before it.
MEDIANS, not means (nearest-rank, shared with the apps dashboard via
`AppsAnalyticsHelper.Percentile`) — one back-stamped straggler must not fire a fleet alert.
A regression fires only when ALL hold over the trailing 35-day horizon:
1. both versions have ≥ 10 measured installs;
2. current median ≥ 2× the previous version's median;
3. absolute increase ≥ 300 s (a 20s→45s app is noise, not an incident).
## Suppression & limits
* Parallel ring rollouts can make "previous" a concurrently-deployed version — accepted in
v1; the episode key (app, current version) still caps it at one bell per version.
* Platform kill switch: app setting `AppVersionRegressionRadarDisabled=true` skips the
radar (fail-open — it only notifies, never mutates data).
* One cross-tenant install-summary read per pass (the same query the global apps dashboard
runs), grouped by tenant; per-tenant failures are non-fatal.
## Alert episodes (tracker keyspace)
An episode lives as `appversionregression|{app-lower}|{version-lower}` (table-key-sanitized;
raw AppName/CurrentVersion live in columns, the mapper never parses the RowKey) in the
notification-tracker table, next to the `ruleregression|` keyspace: the row IS the dedup
(bell + ops event fire exactly once per episode) and the `versionRegressions[]` payload —
both medians, both sample counts, lift. Numbers refresh on every radar pass while the
episode stays active; `FirstNotifiedAt` never moves. The episode closes (dedup re-arms) when
the alerted version drains under 10 measured installs in the horizon or its recomputed
median falls back under 1.5× the previous version's median; the tracker's 30-day retention
sweep also re-arms, so a month-old still-burning regression rings once more by design.
## Notification chain (v1: bell + ops event)
* Tenant bell `app_version_duration_regression` (audience: Admin), href
`/apps/detail?name={app}&days=35` (canonical `appDetailUrl` shape). Message carries the
full numbers so the admin can verify without a portal round-trip.
* Ops event `AppVersionDurationRegression` (Tenant category, Warning) — dual-registered in
the web `OPS_EVENT_TYPES` catalog so operators can wire Telegram/Teams/Slack alert rules.
## Surfaces
* `apps/{appName}/analytics` (+ global twin with `?tenantId=`) responds with
`versionRegressions[]` (empty for the cross-tenant aggregate — episodes are per-tenant
rows), and `versionBreakdown[]` rows now carry `measuredInstalls` /
`medianDurationSeconds` / `p95DurationSeconds`.
* Apps detail page: amber "↑ Duration regression" banner above the version cards and a
"Median Install Duration by Version" bar chart.
# Examples
A tenant ships Contoso VPN 2.4.0 after 2.3.9. 2.3.9 has 40 measured installs, median 660s;
2.4.0 accumulates 12 measured installs with median 1740s. Lift 2.6×, absolute +1080s →
episode `appversionregression|contoso vpn|2.4.0` opens, one bell + one ops event fire.
Subsequent passes refresh the numbers only. When 2.4.1 replaces 2.4.0 and 2.4.0 drains
below 10 measured installs in the horizon, the episode deletes and the dedup re-arms.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs` — pure core
(gates, first-seen ordering, re-arm).
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs`
— orchestration, kill switch, bell/ops-event emission, wording contract.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs`
— episode keyspace, entity round-trip.
* `src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs` — persisted
alert payload.
* [Rule Regression Radar](rule-regression-radar.md) — the sibling pattern this radar mirrors.
-95
View File
@@ -1,95 +0,0 @@
---
type: Concept
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).
resource: /src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs
tags:
- backend
- table-storage
- audit-log
- ops-events
- events
- migration
timestamp: 2026-07-20T00:00:00+02:00
---
# Problem
Azure Table Storage's `Timestamp` is a **system property**: the server ignores any supplied
value on write and stamps the row's *write* time; every row rewrite (storage-account
migration, MERGE) resets it. Three tables nevertheless treated it as the business event
time — writes like `["Timestamp"] = evt.Timestamp` were silent no-ops, unnoticed because
write time ≈ event time in normal operation.
The 2026-07-18 EU storage migration rewrote every row, so all migrated rows in
**AuditLogs**, **OpsEvents**, and **Events** read as "2026-07-18 ~13:58 UTC": displays were
wrong, date-window filters matched the wrong rows, and retention froze (nothing older than
the migration moment) with a mass-delete cliff one retention period later.
# Rule
**The system Timestamp is never authoritative.** Every table that needs an event time
stores it in the custom column `OccurredUtc` (`BusinessTimestamp.OccurredUtcColumn`).
Custom columns are copied verbatim by `scripts/Migration/Copy-TableStorage.ps1`, so future
migrations preserve chronology. Migration checklist: any table whose reads/filters depend
on a timestamp must carry it as a custom column — audit this before every storage move.
# Recovery sources per table
The original time survives migrations inside the RowKey:
| Table | RowKey scheme | Decodable |
|---|---|---|
| AuditLogs | `!{MaxTicks-ticks:D19}_{guid:N}` (since 2026-05-06) | Yes; legacy bare-GUID rows (pre-2026-05) are lost |
| OpsEvents | `{MaxTicks-ticks:D19}` | Yes, all rows |
| Events | `{ts:yyyyMMddHHmmssfff}_{seq:D10}` (sanitized agent time) | Yes, all rows (ms precision) |
# Mechanism
`BusinessTimestamp` (Functions/Helpers) is the single source of truth: RowKey decoders,
the OData clause builders, and `GetUtcDateTime` (datetime columns materialize as
`DateTimeOffset` *or* `DateTime` depending on payload shape — always read via this helper).
1. **Read mappers** resolve `OccurredUtc` → RowKey decode → system Timestamp (last
resort; only audit legacy GUID rows end there). Resolvers:
`TableStorageService.ResolveAuditTimestamp`, `TableOpsEventRepository.ResolveTimestamp`,
`TableStorageService.ResolveEventTimestamp` (also used by
`GetEarliestSessionEventTimestampAsync`, which feeds StartedAt/Duration reconciliation —
a system-Timestamp read there would rewrite migrated sessions' derived fields).
2. **Date-window filters and retention use RowKey ranges**, not property comparisons:
OData excludes rows missing a compared property (an `OccurredUtc` filter would drop
every not-yet-backfilled row), and RowKey ranges are index-backed. Boundary semantics
are tick-exact equivalents of the old `Timestamp ge/le` clauses.
**Trap:** audit legacy GUID RowKeys (hex first char) sort *after* all `!`-rows, so
every lower bound `RowKey ge '!…'` must be paired with `RowKey lt '"'`
(`AuditTimeEncodedUpperBound`) — without the guard, a retention sweep would delete all
legacy rows. Pinned by `BusinessTimestampFilterClauseTests`.
3. **Writes** in all three tables now set `OccurredUtc`; the no-op `["Timestamp"]`
entries were removed.
4. **Backfill** (`POST /api/maintenance/backfill-occurred-utc`, GlobalAdminOnly, dry-run
by default, `table=audit|ops`, batched via `maxRows`/`continuation`): MERGE that adds
only `OccurredUtc` decoded from the RowKey — deterministic and idempotent. **Events
rows are deliberately never backfilled** (read-path decode is sufficient; no bulk
mutation of enrollment evidence). Note: a MERGE resets the row's system Timestamp —
irrelevant once nothing depends on it.
5. **Audit retention** runs two passes until ~2027-01: RowKey-range for `!`-rows plus a
`RowKey ge '0' and Timestamp lt …` pass that ages legacy GUID rows out via their frozen
migration-date system Timestamp (remove after 2027-03-01).
# Consequences
* Audit legacy GUID rows have "unknown date" semantics: excluded from date-filtered
views, shown with the (wrong but honest) 2026-07-18 fallback in unfiltered views.
* Raw endpoints (`/api/raw/*`, `query_table`) intentionally keep showing the raw system
Timestamp — they are storage-state inspectors, not business views.
* Event timestamps for pre-cutover rows resolve to the RowKey's sanitized agent time
(ms precision); `ReceivedAt` (server ingest time, rows since 2026-03-19) and
`OriginalTimestamp` (pre-clamp agent time, clamped rows only) remain separate fields.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs` — decoders + clause builders
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs` — audit write/mapper/filters/retention
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs` — ops write/mapper/filters/retention
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — event writes, `ResolveEventTimestamp`, earliest-event read
* `src/Backend/AutopilotMonitor.Functions/Services/OccurredUtcBackfillService.cs` + `Functions/Admin/BackfillOccurredUtcFunction.cs` — backfill
-235
View File
@@ -1,235 +0,0 @@
---
type: Concept
title: Client-Certificate Tenant Binding — Closing the Cross-Tenant Gap
description: Why a chain-valid Intune MDM certificate does not prove the caller belongs to the tenant it claims, which certificate extension carries the Entra tenant id (and which two look like it but do not), and the shadow-then-enforce rollout that measures field coverage before it can lock devices out.
resource: src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs
tags:
- backend
- security
- authentication
- certificates
- intune
- multi-tenant
timestamp: 2026-08-24T00:00:00+02:00
---
# Problem
Agent requests authenticate with mTLS. `CertificateValidator` pins the chain to the embedded
Intune roots (`X509ChainTrustMode.CustomRootTrust`), checks the validity window and requires the
Client-Authentication EKU. All of that together proves exactly one thing: **the certificate was
issued by the Microsoft Intune MDM Device CA to some tenant**.
Those roots are shared by every Intune tenant on the planet. Nothing in the certificate stage ties
the caller to the tenant in the request. The tenant scoping came entirely from the downstream
device validators — the serial number must appear in that tenant's Autopilot / imported-device /
Cloud PC inventory. Device serial numbers are not secrets; they are printed on the chassis.
So the pre-existing attack was: enroll a device in your own Intune tenant, obtain a legitimate
device certificate, learn one serial number of the victim tenant, and submit telemetry as that
tenant. Certificate validation passes, device validation passes.
# Mechanism
The Intune MDM Device CA stamps the customer's identifiers into the leaf certificate. Since the
agent presents this certificate in the TLS handshake, its contents are proof of possession and the
backend can compare them against the requested tenant — no Graph call, no additional consent, and
nothing the agent has to send.
## Which extension carries what
Verified by decoding a real field certificate (issued 2026-05-08, `Functions.Tests/device-cert-sample.pem`):
| OID | Encoding | Content |
| --- | --- | --- |
| `1.2.840.113556.5.14` | nested OCTET STRING (`04 10` + 16 bytes) | **Entra tenant id** — the one to compare |
| `1.2.840.113556.5.6` | nested OCTET STRING (`04 10` + 16 bytes) | Intune **Account** id — a *different* GUID, never equal to the tenant id |
| `1.2.840.113556.5.4` | **16 raw bytes, no wrapper** | Intune device id, identical to the certificate's Subject CN |
Two traps are worth stating explicitly, because both cost real debugging time:
1. `…5.6` is widely described as "the tenant id (AccountID)". It is not the Entra tenant id.
Comparing it against the requested tenant would reject every request in production.
2. The encodings are not uniform. `…5.4` holds the GUID bare while the other two nest it in a
second OCTET STRING, so a decoder written against one shape silently fails on the other.
`MsDeviceCertificateOids.TryParseGuid` accepts both, and all GUIDs are Microsoft
little-endian (what `new Guid(byte[])` expects).
A 16-byte input is inherently ambiguous — it is indistinguishable from a truncated wrapper and is
always read as a bare GUID. That is harmless here: the value is only ever compared for equality
against a known tenant id, never trusted as a standalone claim.
## Where it is NOT
`EntraDeviceCertHelper` reads tenant and device ids from the **MS-Organization-Access** certificate
(`1.2.840.113556.1.5.284.*`). That certificate is not part of the authentication path at all — the
agent selects the Intune MDM certificate by exact issuer match (`CN=Microsoft Intune MDM Device CA`,
see `CertificateHelper.SelectMdmCertificate`) and the backend pins only the MDM chain. The Entra
certificate is used purely as a local fallback for `TenantIdResolver`; it never leaves the device.
Sending it in a header instead would prove nothing: only the certificate used in the TLS handshake
demonstrates possession of a private key. A certificate copied into a custom header can be replayed
by anyone who has seen it once.
# Rollout
Enforcing immediately would be reckless: any device population whose certificates predate the
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
rollout therefore has staged consequences.
**Stage 1 — shadow (completed, kept as the measurement that justified stage 2).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
`Unparseable`, `RequestTenantNotAGuid`) are stable and queried by exact match in KQL.
`WouldRejectUnderEnforcement` records what stage 2 *would* have done, so the telemetry can be read
without re-deriving the rule at query time.
The telemetry uses two carriers, because the numerator and the denominator have very different
costs and answer different questions:
* **Every** outcome, `Match` included, is stamped onto the request row as the `CertTenantBinding`
dimension — `SecurityValidator` writes it into `FunctionContext.Items`
(`CertTenantBinding.RequestItemKey`) and `RequestTelemetryMiddleware` copies it onto the
`RequestTelemetry` it already emits. That row exists per request and is unsampled, so the
denominator costs no additional telemetry at all.
* Outcomes that need an enforcement decision (mismatch, missing, undecodable) are **additionally**
logged at Warning with the full context — thumbprint, certificate tenant, session.
`Match` is deliberately not a trace line. Worker-side `LogInformation` never reaches App Insights
(the ApplicationInsightsLoggerProvider default rule is Warning+ and host-forwarding of worker logs
is disabled), so a `Match` trace is dead code that silently costs the denominator. The first
deployment of this feature made exactly that mistake: it emitted zero rows across 32k requests,
which is indistinguishable from the code never running.
**Stage 2 — enforced since 2026-08-25.** `Mismatch` returns 403 and is recorded through the shared
`LogRequestRejection` path (`stage=certtenant`) so it lands in the same KQL raster as every other
rejection reason. The switch was made against measured data: 35,684 requests across 32 tenants,
every one a `Match`, with the Warning channel independently verified to be carrying other traffic so
the zero could not be a telemetry artefact.
The rule lives in one place, `CertTenantBinding.Rejects`, and only `Mismatch` blocks. Everything
else means "cannot tell" and is deliberately tolerated:
* `ExtensionMissing` — the sample only covers devices active in the measurement window, so older
certificates or a sovereign-cloud CA could still lack the extension. Rejecting on absence would
lock those devices out for something they cannot control. **This is the one to tighten later**,
once the `ExtensionMissing` rate is provably zero across a wider window; a test pins the current
tolerance so removing it has to be a deliberate decision rather than a drive-by edit.
* `Unparseable` — a decoder or encoding surprise is our problem, not the device's.
* `RequestTenantNotAGuid` — nothing to compare against, and such a request fails the tenant lookup
in §0 long before reaching this point.
The evaluation fails open on its own exception: this gate exists to stop a cross-tenant certificate,
and a defect on our side is not evidence of one. The 403 body deliberately does not echo which
tenant the certificate belongs to — that pair is in the rejection log, not in the response.
Grep marker for every site involved: `CERT-TENANT-BINDING`.
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
**Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was
issued to this tenant; it does not prove the certificate belongs to a device the tenant still has.
`IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the
certificate's Subject CN against the tenant's own `deviceManagement/managedDevices` inventory
(point-GET on `managedDevices/{id}` — no OData filter to inject into, and a 404 is unambiguous).
It is modelled on `CloudPcDeviceValidator` and shares its cache/retry/transient contract, with one
important difference in how it is wired: the lookup is fire-and-forget, like the DevPrep
association shadow, because a cold Graph round-trip can take seconds and must never sit in the
agent's request path. That rules out the request-row dimension stage 1 uses — the row is written
the moment the function returns, before the detached task finishes — so the outcome is logged at
Warning instead. Volume stays bounded because only Global Admins can see the toggle
(`user?.isGlobalAdmin === true`, the same gate as the DevPrep preview). Widening it beyond preview
should move to an inline call plus a request dimension.
Outcomes: `Match`, `NotFound`, `NoDeviceIdInCert`, `PermissionMissing`, `Transient`. Only
`Transient` is retried and left uncached — a missing grant or a foreign device is a state, not an
outage.
Graph cost is not a concern, and that is measured rather than assumed: the first live enrollment
produced **136 requests and exactly one Graph call**, because the 30-minute positive cache absorbs
the rest. For comparison, the per-request Autopilot serial validator made 25 calls in the same
window. Lengthening the positive TTL would buy nothing and would delay noticing a device that has
been wiped or removed; shortening the 5-minute negative TTL matters more, since that is how quickly
a device object appearing late is picked up.
What the 136 requests *did* cost was log lines saying the same thing 136 times, so the shadow log is
gated on `IntuneDeviceBindingResult.ServedFromCache`: one line per real Graph lookup. Cached repeats
add nothing — the reported age just counts up mechanically from the same `enrolledDateTime`.
Outcomes that are never cached (`Transient`, `NoDeviceIdInCert`) still log every time, which is the
right behaviour for an anomaly.
The open question this preview exists to answer is a race, not an attack: a device object can in
principle appear in Intune *after* the agent's first call, and enforcing before that is understood
would reject legitimate enrollments. Every result therefore carries `enrolledDateTime`, and the log
line records `enrolledAgeSeconds` — a device whose object was created seconds before the request
looks completely different from a certificate that never belonged to the tenant. Grep marker:
`CERT-DEVICE-BINDING-SHADOW`.
Enabling it needs the optional Graph permission `DeviceManagementManagedDevices.Read.All`, granted
per tenant with the customer script — it is not in the default-consent set:
```powershell
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<client-id>" -Features IntuneDeviceBinding
```
`GraphFeatureCatalog` (feature `IntuneDeviceBinding`) and the script's catalog, `ValidateSet` and
help block are pinned to each other by `GraphFeatureCatalogSyncTests`.
# Examples
Coverage — the question stage 2 depends on ("would enforcing lock anyone out?"):
```kusto
requests
| where isnotempty(customDimensions.CertTenantBinding)
| summarize Requests = sum(itemCount)
by Outcome = tostring(customDimensions.CertTenantBinding),
Tenant = tostring(customDimensions.TenantId)
| order by Requests desc
```
Detail on the individually actionable ones:
```kusto
traces
| where message has "AgentCertTenantBinding"
| extend Outcome = tostring(customDimensions.Outcome),
Tenant = tostring(customDimensions.TenantId)
| summarize count() by Outcome, Tenant
```
Anything in `Mismatch` is a cross-tenant certificate; anything in `ExtensionMissing` is a device
that stage-2 enforcement would have to make a policy decision about. Note that the trace query
alone can never show `Match`, so it cannot tell "everything matched" apart from "nothing ran" —
use the request-row query for that.
Stage 3, including the enrollment-race measurement:
```kusto
traces
| where message has "AgentCertDeviceBinding"
| extend Outcome = tostring(customDimensions.Outcome),
AgeSecs = toint(customDimensions.EnrolledAgeSeconds)
| summarize Requests = count(),
MinAgeSecs = min(AgeSecs),
P05AgeSecs = percentile(AgeSecs, 5)
by Outcome
```
`NotFound` is the row that decides whether this can ever become a gate. A small `MinAgeSecs` on
`Match` means device objects are appearing only just in time — the race is real and enforcement
would need a grace path.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
shared ASN.1 GUID decoder (one implementation for agent and backend).
* `src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs` — extension extraction
alongside chain validation, cached per thumbprint.
* `src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs` — the pure comparison.
* `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — shadow observation.
* [W365 Cloud PC Device Validation](cloudpc-device-validation.md) — the other consumer of this
certificate's identity fields (Subject CN = Intune device id).
-142
View File
@@ -1,142 +0,0 @@
---
type: Concept
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs
tags:
- backend
- security
- device-validation
- windows365
- cloudpc
- graph
timestamp: 2026-08-06T00:00:00+02:00
---
# W365 Cloud PC Device Validation
Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are
**structurally never Autopilot-registered** — the serial lookup against
`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose
serials nobody can pre-import for VMs minted at provisioning time) always misses.
Before this validator, a Cloud PC agent died in the 403 "Device not registered"
even though its mTLS cert was perfectly valid, so Cloud PC first-connect
enrollment (Account Setup, RealmJoin, Hello) could not be monitored.
# Schema — the validator chain
`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every
device; there is **no device-type routing** and the local W365 markers
(Windows365 registry key, CloudManagedDesktopExtension service) are never
consulted for auth — they gain an attacker nothing:
1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary.
2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback.
3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2
missed. Extracts the Intune device id from the **chain-validated** client
certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present
AND canonical GUID, else definitive reject) and requires a `cloudPC` object
with `managedDeviceId eq <CN>` in the claimed tenant
(`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`).
`ValidatedBy = ValidatorType.CloudPc (5)`.
Only machines actually provisioned by the Windows 365 service have a `cloudPC`
object — a regular Intune-enrolled non-Autopilot device still ends in the 403,
so the "Autopilot devices only" contract of stages 1–2 is not widened. The
tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag.
## Why the cert CN, not headers or local markers
Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net):
- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` ==
`cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM
Device CA (chain pinned to embedded roots, `CustomRootTrust`).
- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no
paging fallback needed.
- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly;
the "Cloud PC Enterprise …" model string exists **only** in Graph, never in
local WMI (local WMI reports Manufacturer='Microsoft Corporation',
Model='Virtual Machine').
Threat model: a valid Intune cert from a *different* tenant fails (its CN does
not exist in the claimed tenant's Cloud PC inventory — this is a *tenant
binding* the serial-based validators do not have); spoofed serial headers and
faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM
with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not
device-reported inventory — the reason the model-string gate was rejected).
Retired/deprovisioned Cloud PCs disappear from the inventory and their
validation dies with them (compensates the absent cert revocation check).
Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level
as any tenant device today, rate-limited by cert thumbprint.
## Graph permission plumbing
- Optional add-on permission `CloudPC.Read.All` — NOT part of the default
consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` →
surfaces automatically in the "Optional Graph capabilities" UI (the feature
table and grant command are backend-driven) and in
`Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`).
`GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously
comment-only lock-step).
- A Graph **403** (permission not granted) is a definitive negative (cached
5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant.
The error message names the add-on feature.
- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min
positive/negative cache, 2 attempts, transient → agent-facing 503.
## Bootstrap script interplay (v2.3-dev.3)
The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts
**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the
user's first connect, and the monitorable phase (Account Setup) only starts at
that first connect — the single-fresh-profile window (< 15 min) is the time
anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard.
## Agent-side IsCloudPc flag + decision-engine expectation
Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's
field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus**
installed `CloudManagedDesktopExtension` service (probed via its
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
Three independent transport rails, never an auth input:
- **Session metadata** — `SessionRegistration.IsCloudPc` →
Sessions table (sticky-true OR across re-registrations, like
`IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` →
search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`,
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
(e.g. Cloud PC admitted via a stage that ran earlier).
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
Registered" report aggregates it **sticky-true** per serial
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
badge plus a hint pointing at the Cloud PC validation toggle + the
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
fields; rows from older agents read false. Fits the existing 1536-byte
payload cap without a bump.
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
(`isCloudPc`), is recorded set-once for BOTH values in
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
reason `cloud_pc_first_connect:no_device_esp_expected`. That is the
engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran
headless at provisioning, the session starts at Account Setup. Deliberately
reason+observation only — no completion arm depends on DeviceSetup, `Mode`
is still classified by AccountSetup/IME signals (Classic), and `EspConfig`
keeps the *configured* FirstSync semantics. The positive marker surfaces as
`cloud_pc_marker` in the terminal audit-trail signal census.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject`
- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation`
- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant
- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption
- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND
- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater`
-123
View File
@@ -1,123 +0,0 @@
---
type: Concept
title: Device Journeys & First-Time-Right (F2)
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.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs
tags:
- backend
- metrics
- device-history
- first-time-right
- insights
timestamp: 2026-07-27T12:00:00+02:00
---
# Device Journeys & First-Time-Right (F2)
Answers "how many devices enrolled right on the first try?" and "show me every attempt
this device made". Session success rate hides wipe-and-retry loops — every retry that
eventually succeeds counts as a success; First-Time-Right (FTR) exposes the real pain.
All grouping semantics live server-side in `DeviceJourneyCalculator` (JourneyVersion 1);
no consumer re-derives them.
# Schema
## Device key
`(TenantId, normalized serial)` — normalization is trim + lower-case. Junk/placeholder
serials are excluded entirely (never a chain, never in FTR, disclosed as a per-day
exclusion count): `System Serial Number`, `To Be Filled By O.E.M.`, `Default string`,
`0`, `None`, `INVALID`, `Unknown` (the agent's WMI-failure sentinel), empty, or shorter
than 4 characters. A motherboard swap changes the serial and therefore the device key;
re-enrollment under a different tenant is a separate key by design.
## Journey
Terminal sessions of a device key ordered by StartedAt. The terminal set is
**Succeeded / Failed / Incomplete** — `Pending` (a sealed WhiteGlove part 1),
`AwaitingUser`, `Stalled` and `InProgress` are OPEN sessions and never attempts.
* A journey **ends** with the first terminal success; the next session starts a new
journey (redeployment).
* A gap of **more than 30 days** since the previous attempt's terminal moment
(`CompletedAt`, fallback StartedAt) starts a new journey even without a success
(device shelved/repurposed). Constant, not a setting — production gaps: median 4.9 h,
92 % < 7 d.
* WhiteGlove part 1 + part 2 share ONE session row, so a completed WG enrollment is ONE
attempt; a WG device still waiting for its user session leaves the journey open —
never a failed attempt.
* **Attempt count** = terminal sessions in the journey (`Failed` and `Incomplete` are
non-successful attempts).
* **FTR rate** = completed journeys with attempt count 1 ÷ all completed journeys.
Open and gap-abandoned journeys never enter numerator or denominator.
Durations shown for attempts are the sessions' authoritative `DurationSeconds` verbatim
(WhiteGlove pause excluded; `Incomplete` deliberately has none) — never
`CompletedAt − StartedAt`, which is later in ~25 % of terminal sessions.
## Persistence & compute
* `DeviceHistories` (PK = TenantId, RK = percent-encoded normalized serial): the chain of
the device's terminal session refs `{sessionId, startedAt, completedAt, status,
enrollmentType, isPreProvisioned, durationSeconds, adminMarked}` capped at the 20 most
recent, plus derived `CurrentJourneyAttempts` (the LAST journey's attempts — the
banner's "Attempt N"), `JourneyCount` (chain-scoped) and `JourneyVersion`. Written
inline at the session-terminal single-writer seam
(`TableSessionRepository.UpdateSessionStatusAsync`, all three terminal statuses,
DeletionState-guarded) so the session banner is fresh; healed by the maintenance sweep.
Deliberately NOT part of the per-session deletion manifest — the row aggregates many
sessions. Wiped on tenant offboarding.
* Sweep (`SweepDeviceJourneysAsync`, 30-day rolling window, 2h tick): (1) drops chain
refs of deleted sessions **tombstone-driven** — every session deletion (cascade and
retention both run through the cascade worker) leaves a `SessionTombstones` marker for
~7 days, far longer than the sweep cadence, and even an expired-but-unpruned marker is
valid deletion evidence; a chain left empty deletes its row. (2) Merges the window's
terminal sessions into their chains (backfill, inline-miss healing, re-terminal
reclassifications). (3) Recomputes the daily FTR aggregates idempotently.
* `DeviceJourneyAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}`): per day —
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
success session (the platform's StartedAt-date convention; late terminals converge via
the sweep, which slices the tick's shared projected window scan — see
`time-attribution.md`). All counts are **additive**, so a window rate is the ratio of summed daily
rows — there is no rolling-window row (unlike the median-based time-attribution
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
on purpose (the UI needs the n for "insufficient data (n=…)").
# Surfaces
* `GET api/metrics/device-history?serialNumber=&sessionId=` (MemberRead + tenant query
param — the same route serves the web banner and MCP; a Global Admin passes `tenantId`)
— the chain plus, with `sessionId`, that session's server-computed attempt number
(live sessions get their would-be position via a virtual non-successful attempt, so the
redeploy and gap rules apply). `history: null` = no recorded history.
* `GET api/metrics/device-journeys?days=` (MemberRead) /
`GET api/global/metrics/device-journeys?days=&tenantId=` (GlobalReadOrAdmin) —
`{ windowDays, totals, daily, repeatDevices }`. The days selector is honored because
counts are additive. Without `tenantId` the global variant serves the cross-tenant
aggregate rows and `repeatDevices: null` — a per-device list would require scanning
every tenant's partition.
* Repeat devices (violator list, capped 10): devices whose LAST journey took ≥2 attempts
and whose newest terminal session falls inside the window, ordered by attempts then
recency; the failure reason comes from the newest failed attempt (bounded point-reads).
* Web: "Attempt N for this device · View history" banner on session detail (expandable
chain); "First-time-right" section on Fleet Health (rate + weekly trend + attempt
histogram + repeat devices). MCP: `get_device_history` (serial mode / fleet FTR mode).
# Examples
A device fails ESP twice and succeeds on the third try within one week: one journey,
three attempts, completed — counts once in the FTR denominator, not in the numerator,
and the histogram's bucket `3` gains one. The same device redeployed a month later and
succeeding immediately starts journey 2 with attempt count 1 — first-time-right. A
device with `serialNumber = "Unknown"` (WMI failure) never gets a chain; its terminal
sessions surface only as that day's disclosed exclusion count.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs` — device key, junk list, grouping, attempt numbers (JourneyVersion 1).
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceHistory.cs` — persistence + inline seam entry.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.DeviceJourneys.cs` — sweep, tombstone pruning, daily aggregation.
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs` — read surfaces.
* `src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs` — table-driven grouping/aggregation/attempt-number tests.
* `tasks/insights-expansion-spec.md` — F2 specification and source-data audit (§0.5).
-114
View File
@@ -1,114 +0,0 @@
---
type: Concept
title: IME Installer Archive (version-verified, multi-host)
description: How every fleet-observed Intune Management Extension build is archived as its MSI - the ime_agent_version sighting that queues the job, the candidate hosts, the ProductVersion check that keeps a wrong ring's build out of the version folder, the re-queue on later sightings, and the manual skill-side backfill.
resource: src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs
tags:
- backend
- ime
- archive
- blob
- queue
timestamp: 2026-08-29T17:30:00+02:00
---
# IME Installer Archive
The platform keeps every Intune Management Extension build it has ever seen in the fleet
as the original installer, so any IME version can be decompiled and diffed later
(`ime-decompiles` repository, `/ime-decompile` skill) even after Microsoft's CDN has moved
on. Two stores:
| Store | Content | Written by |
| --- | --- | --- |
| Blob container `ime-archive` (private, kept by design, not tenant-scoped) | `{version}/IntuneWindowsAgent.msi` (write-once) + `{version}/provenance.json` (derived, overwritable) | `ImeMsiArchiver` via the `ime-msi-archive` queue; `/ime-decompile` skill as manual backfill |
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
# Schema
## Sighting → job
`EventIngestProcessor` looks at the batch's `ime_agent_version` event (`agentVersion`, plus
the agent's CSP-registry enrichment `msiDownloadUrl` / `msiMatchedBy` when present).
`RecordImeVersionAsync` returns an `ImeVersionSighting`:
* `IsNew` — the insert succeeded: first fleet-wide sighting → ops event + enqueue.
* otherwise the row's `MsiArchiveStatus` / `MsiArchiveUpdatedAt`, read in the same point
read that bumps `SessionCount` (no second table read).
A known version is **re-queued** (`ImeMsiArchiver.ShouldRequeueOnSighting`) only when all
of these hold: the event URL passes the allowlist **and** `msiMatchedBy == productVersion`
(the CSP row's ProductVersion equals the observed version, so the URL is authoritative),
the status is neither `Archived` nor `Failed:BadVersion`, and the last archive activity is
older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merges
`MsiArchiveStatus = Queued` (stamping `MsiArchiveUpdatedAt`) before enqueueing, so the other
sessions of that version see "in flight" and do not pile on. Worst case for a version no
host serves any more: one walk over the hosts per day, not one per enrollment.
## Candidate hosts and the version check
Microsoft distributes IME from several versionless URLs that carry **different rollout
rings at the same time**. Observed 2026-08-29: `imeswdb-afd-secondary` served 1.104.102.0,
`imeswda-afd-primary` 1.105.101.0, `imeswda-afd-hotfix` 1.105.103.0. "A new version is by
definition what the canonical URL serves right now" is therefore false, and the first
version of the archiver filed a 1.104.102.0 package under `1.105.103.0/` (fixed by hand:
blob overwritten, row merged).
The archiver walks `BuildCandidateUrls(eventUrl)`: the allowlisted event URL first (HTTPS,
host `*.manage.microsoft.com`, filename exactly `IntuneWindowsAgent.msi`), then
`FallbackMsiUrls` = primary, secondary, hotfix — de-duplicated. For each candidate:
1. Download with the admin size cap (Content-Length preflight + mid-stream cap) into a
delete-on-close temp file while SHA-256 hashing.
2. Read the package's `ProductVersion` with `MsiProductVersionReader` — a pure managed OLE
compound-file reader (v3 512-byte and v4 4096-byte sectors, DIFAT/FAT/mini-FAT, MSI
stream-name decoding, `_StringPool`/`_StringData` incl. long strings and holes, the
column-major `Property` table with 2- or 3-byte string refs). It never throws; anything
unreadable is `null`. Needed because the Function host is Linux — no `msiexec`.
3. `VersionsMatch(observed, productVersion)` (component-count tolerant: `1.105.103` ==
`1.105.103.0`). Mismatch or unreadable → log, record the attempt, next candidate.
4. Match → write-once upload (If-None-Match:*), then `provenance.json` with
`productVersion`, the chosen `url`, `urlFromEvent`, hash/size and the full `candidates`
walk (`url`, `outcome` ∈ match / version-mismatch / too-large / download-failed /
timeout, `productVersion`).
Outcome precedence when no candidate archived: any transient failure (download/timeout)
→ `Failed:Download` / `Failed:Timeout`, retryable via the queue's visibility ladder (the
down host might have had it); every candidate over the cap → `Failed:TooLarge`; otherwise
`Failed:VersionMismatch` — **not** retryable by the queue (retrying in minutes changes
nothing), left to the sighting re-queue above. Storage/unexpected errors stay
`Failed:Error`, retryable. A 409 on the write-once upload means an earlier attempt
archived the version: the provenance sidecar is healed from the archived bytes (hash,
size, ProductVersion) if missing — this is also how a manual skill backfill turns a null
row into `Archived` on the next authoritative sighting.
## Manual path (skill)
`/ime-decompile` downloads from the blob (`ime-blob.sh download`), and
`fetch-ime-decompile.py` names the decompile folder after the **actual** FileVersion of
the extracted binaries — so the decompile repo can never be poisoned, only the blob folder.
When the blob's version mismatches, look for `msiDownloadUrl` in *other* sessions of the
version (the first-seen session often lacks the enrichment) and fetch from that host.
`ime-blob.sh upload … --overwrite` replaces a blob known to hold the wrong build; merge the
row's `MsiSha256` / `MsiBytes` / `MsiSourceUrl` afterwards.
# Examples
* First sighting of 1.105.103.0 with no URL: primary → 1.105.101.0 (mismatch), secondary →
1.104.102.0 (mismatch), hotfix → 1.105.103.0 (match) → archived from hotfix, provenance
lists all three.
* Hotfix host down during the walk, others mismatching → `Failed:Download`, queue retries.
* Version 1.101.111.0 (predates the archiver, row status null): a session whose CSP row
matches by ProductVersion arrives → status `Queued`, job runs; if no host serves it any
more → `Failed:VersionMismatch`, next try no sooner than 24 h later.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/MsiProductVersionReader.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiveQueueWorker.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` (ime_agent_version block)
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` (`RecordImeVersionAsync`, `UpdateImeVersionArchiveInfoAsync`)
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeMsiInstallSourceProbe.cs` (event enrichment)
* `.claude/commands/scripts/ime-blob.sh`, `.claude/commands/scripts/fetch-ime-decompile.py`
* Tests: `ImeMsiArchiverTests`, `MsiProductVersionReaderTests` (synthetic packages + local sweep over `ime-files/`)
@@ -1,62 +0,0 @@
---
type: concept
title: Lifecycle Manifests & Session Scope Resolution
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
resource: backend
tags: [lifecycle, offboarding, sessionsindex, manifest, scope-resolution, search, performance]
timestamp: 2026-08-13
---
# Schema
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
## 1. Table lifecycle buckets
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
| Bucket | Mechanism | Source of truth |
|---|---|---|
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
## 2. SessionsIndex field manifest
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
## 3. Session scope resolution (one implementation)
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
- Non-global callers always keep the middleware-validated `TargetTenantId`.
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
- `requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
## 4. Server-side dashboard search
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
# Examples
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
# Citations
- `src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs`
- `src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs` (`ResolveSessionScopeAsync`)
- `src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs` (Variant D)
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` (`MatchesFreeText`, backfill loop)
-107
View File
@@ -1,107 +0,0 @@
---
type: Concept
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
tags:
- backend
- bootstrap
- security
- oobe
timestamp: 2026-07-28T00:00:00+02:00
---
# OOBE Bootstrap go-Code Flow
Bootstrap Sessions let a technician install the agent **before** Intune
enrollment: an admin creates a short-lived session in the portal (Settings →
Bootstrap Sessions), hands the technician a short URL, and the technician types
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
during OOBE. The URL is deliberately short because it is typed by hand on a
device that has no clipboard.
# Lifecycle
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
`PublicAnonymous`). Customers reach it through the Front Door custom domain
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
Validation runs in-process (`ValidateCodeAsync` + the tenant's
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
3. **Install** — the generated script downloads the agent ZIP from
`Constants.AgentDownloadBaseUrl`, extracts, and runs
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
from OOBE start, switches to certificate auth after Intune enrollment, and
self-destructs on completion.
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
URLs issued before the migration and is removed with the static-export PR —
see the transition rule in [URL Registry](../url-registry.md).
# Always-200 contract
Every response — success, invalid format, unknown/expired code, disabled
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
exception on the OOBE console, while a 200 error script surfaces the actual
message via `Write-Host 'ERROR: …'`. Consequences:
- Failures are invisible in status-code metrics; the compensating signal is the
`LogWarning` per rejection (code + client IP).
- Error messages are capped at 200 chars **before** quote-escaping and
single-quote-doubled — a hostile upstream message cannot break out of the
`Write-Host` literal.
A disabled tenant returns the **same generic message** as an unknown code — no
enumeration oracle for whether a code exists.
# Injection defense (two lines)
The script runs as SYSTEM during OOBE, so interpolation is treated as a
security boundary:
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
GUIDs, the download URL must be https on an allow-listed host
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
though values are produced in-process — defense-in-depth against tampered
table data.
2. **Single-quoted PS literals** — every interpolated value sits inside
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
backticks.
The template is a C# raw string with `__TOKEN__` placeholders substituted via
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
TypeScript template through a golden file, so the legacy route and the backend
serve identical scripts during the transition.
# Rate limiting behind Front Door
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
IPs are keyed, not AFD egress.
Front Door route caching is DISABLED for this route: the body inlines the
bearer token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
@@ -1,93 +0,0 @@
---
type: concept
title: Plan Downgrade Policy & the Retention Grace Period
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
resource: backend
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
timestamp: 2026-08-22
---
# Schema
## The downgrade model: gate, don't destroy
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
(≤ ~5 min config cache):
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
assignments are never touched. Resurrects on re-upgrade.
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
**Retention is the one exception**: the retention sweep (12-hourly
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
irreversibly cascade-deleting the 90–365-day band on the next tick.
## The retention grace period (30 days)
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
swept, grace or not.
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
returns null while effectively Pro; grace end = latest anchor + 30d):
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
the trial timestamp disappears and could not anchor). Cleared on any transition back to
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
resets the grace clock.
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
(read-time, like edition resolution). A planTier downgrade under a still-active trial
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
anchors the grace then.
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
which is correct: no tenant had been downgraded before the feature existed.
## Ops visibility (dual-registered event types)
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
response body also returns `retentionGraceEndsUtc`.
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
so Telegram alert rules can route them.
## Deliberate non-goals
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
grace a tenant admin cannot SET a value above the Community cap. The grace protects
existing data; new writes follow the new plan.
* The feature-flags endpoint keeps publishing the plain edition cap
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
-112
View File
@@ -1,112 +0,0 @@
---
type: Concept
title: Rule Regression Radar (F3)
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.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs
tags:
- backend
- rules
- regression-radar
- insights
timestamp: 2026-07-27T16:00:00+02:00
---
# Rule Regression Radar (F3)
Detects "this analyze rule suddenly fires much more often" before helpdesk volume does, and
says what the spike correlates with — with honest statistics and honest wording. Analyze
rules only in v1: their daily stats are audit-verified session-deduplicated
(`FireCount` ≈ distinct sessions with a hit, `SessionsEvaluated` the same-source
denominator). Gather rules are excluded until their per-batch stats dedup is fixed —
never silently included with bad math.
# Schema
## Signal & detection
Per (tenant, ruleId), daily hit rate = `FireCount ÷ SessionsEvaluated` from the
`RuleStats` rows — RATES, not counts (absolute counts spike with every rollout wave).
A regression fires only when ALL hold for the trailing 7-day window vs. the prior
28-day baseline:
1. window has ≥ 5 hit sessions AND ≥ 20 evaluated sessions;
2. window rate ≥ 2× baseline rate;
3. the Wilson 95 % intervals are disjoint in the increase direction
(`MetricsMath.WilsonInterval` / `RateIncreaseSeparated` — deterministic, unit-pinned),
so small-n noise never alerts.
## Suppression (false positives are trust damage)
* Rule entity missing (deleted) — never alerts.
* Rule younger than baseline + window (`CreatedAt` within 35 days — grace period for new
rules; the entity timestamp is the honest gate, a stats-row count would fail on
low-volume tenants whose quiet days simply have no rows).
* Rule edited inside the window (`UpdatedAt` ≥ window start) — edits legitimately change
hit rates.
* Empty baseline denominator (rule disabled throughout the baseline).
* Platform kill switch: app setting `RuleRegressionRadarDisabled=true` skips the radar
(fail-open — it only notifies, never mutates data).
## Alert episodes (tracker keyspace)
An episode lives as `ruleregression|{ruleId}` in the notification-tracker table
(the `tpmpss|` pattern): the row IS the dedup (bell + ops event fire exactly once per
episode), the badge state and the `regressions[]` payload — window/baseline counts,
rates, lift (null for a zero-baseline "new signal" — no finite lift is invented), window
dates, and the dimension concentration captured at first fire. Numbers refresh on every
radar pass while the episode stays active; `FirstNotifiedAt` never moves. The episode
closes (badge clears, dedup re-arms) when the window rate falls back under 1.5× baseline
or fires stop entirely; the tracker's 30-day retention sweep also re-arms, so a
month-old still-burning regression rings once more by design.
## Dimension correlation (on fire only)
Hit sessions (RuleResults scan: tenant partition range + RowKey = ruleId + DetectedAt in
window, intersected with the window's sessions) are compared against all window sessions
across `osBuild`, `model` (manufacturer + model), `agentVersion` and `imeVersion`. The
top value by lift is reported with BOTH shares — wording contract: "X % of affected
sessions are on … vs Y % of all sessions — correlated, not necessarily causal". No value
with ≥ 5 hit sessions at ≥ 2× lift → "no clear dimension concentration"; a correlation
failure also yields no claim. App-version correlation for `ANALYZE-APP-*` rules is a
follow-up (needs rule→app matching semantics), tracked with the gather-rule fix.
## Compute & alert path
Runs in the daily maintenance pass right after the rule-stats aggregation
(`RunRuleRegressionRadarAsync`), anchored on YESTERDAY — whole days only, a partial
today would understate the window rate. One 35-day stats read covers all tenants;
evaluation is the pure `RuleRegressionRadar` core. On a NEW episode:
1. `RuleFrequencyRegression` ops event (category Tenant, Warning) with the full numbers
— dual-registered in the web `OPS_EVENT_TYPES` catalog for alert-rule routing.
2. Tenant bell notification (type `rule_frequency_regression`, Admin audience) with the
same numbers and a deep link to the rule's card (`/analyze-rules#rule-card-{ruleId}`).
A sessions-list pre-filtered to rule hits needs a rule filter the session search does
not have yet — follow-up alongside the gather-rule work.
3. v1 channel: bell + ops event only. Provider fan-out (webhook/Telegram) follows once
alert quality is proven — a noisy first version burns trust permanently.
# Surfaces
* `GET api/metrics/rule-stats` (and the global variant with `tenantId`) carries
`regressions[]` — the active episodes. MCP `get_rule_stats` inherits the block.
* Analyze-rules page: per-rule 30-day fire-count sparkline (densified from the `trend`
rows the endpoint already served) and a red "↑ Regression" badge while an episode is
active, with the full numbers in the tooltip.
# Examples
A rule firing on 2 % of sessions for a month jumps to 15 % over a week (15/100 vs
10/500): lift 7.5×, Wilson-separated → one bell, one ops event, badge until the rate
falls back under 3 %. The same jump on 5/20 sessions with a 12 % baseline stays silent —
the intervals overlap, so the spike is not statistically real yet. A rule that never
fired in the baseline and now hits 15 % alerts as a "new signal" with no lift figure.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs` — gates, suppression, re-arm, dimension concentration.
* `src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs` — Wilson interval + separation primitive.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.RuleRegression.cs` — sweep, episode reconcile, bell/ops wiring.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs` — episode keyspace.
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleRegressionRadarTests.cs` — hand-computed Wilson vectors + every suppression branch.
* `tasks/insights-expansion-spec.md` — F3 specification and source-data audit (§0.5).
@@ -1,84 +0,0 @@
---
type: concept
title: Send-Time Frame Separation (X-Send-Time-Utc)
description: One device-clock send timestamp per upload batch splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset — turning clock diagnostics from median statistics into direct per-request measurement.
resource: src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs
tags: [backend, agent, ingest, timestamps, clock-skew, telemetry, provenance]
timestamp: 2026-08-21
---
# Problem
Events carry two time anchors: `OccurredUtc` (device clock at emission) and `ReceivedAt`
(server clock at ingest). Their difference conflates two physically distinct phenomena:
* **Spool delay** — the event waited offline or in retry backoff before upload. Expected,
harmless, minutes to hours.
* **Device-vs-server clock offset** — the actual diagnostic signal.
A uniform spool backlog (events emitted close together, uploaded after an outage) is
indistinguishable from a clock offset in that delta; a draining backlog fakes a clock jump
ramp. Every consumer had to filter this statistically (spread caps, plateau guards,
persistence tests) — and the first field clock-jump measurement failed exactly on this
ambiguity.
# Schema
The agent stamps one header per upload attempt (`BackendTelemetryUploader`):
```
X-Send-Time-Utc: <DateTime.UtcNow, ISO-8601 round-trip>
```
Per **attempt**, not per batch content: a retry after backoff is a new send moment. The
ingest function parses it (`ParseSendTimeHeader`: round-trip parse, UTC-normalized, values
before year 2000 rejected as garbage — deliberately **no upper clamp**, a future-dated
send time IS the clock-error measurement), and `StampServerFields` writes it as `SentAt`
onto every event of the request — server-stamped, request-level, never trusted per-event
from the wire. Stored as the nullable `SentAt` column on Events (absent for pre-header
agents), mapped back everywhere events are read, exposed as `sentAt` in the `fields=`
projection and verbatim on the raw endpoints.
The separation is then exact:
```
SentAt − OccurredUtc = pure spool delay (both device frame; offset cancels)
ReceivedAt − SentAt = network latency + clock offset (the only frame crossing)
```
Network latency is seconds; anything beyond is device clock error, per request, no
statistics required.
# Consumers
* **`clock_skew` analyze condition** (ANALYZE-DEV-008): batches whose events carry
`SentAt` measure the batch offset directly as `SentAt − ReceivedAt` — spool-immune by
construction, so the spread cap and the IME/clamped exclusions are unnecessary there
(no event timestamps are read at all; IME-only sessions become measurable). Batches
without `SentAt` keep the legacy per-event median + spread-cap path. Downstream logic
(stability check, plateau step detection, end-state persistence) is shared; evidence
carries `sentAtBatchCount`.
* **`CmTraceSkewTripwire` deliberately unchanged**: `median(Δ_IME) − median(Δ_other)`
cancels the device clock offset as common mode — send time adds nothing to that
differential measurement.
# Constraints
* `SentAt` comes from the same device wall clock as the event timestamps — a clock jump
between emission and send lands in the spool-delay term. That is the intended place for
it to become visible, not a defect.
* Signals and DecisionTransitions do not carry `SentAt` (no consumer); the header is
request-level, so extending them later is a storage-only change.
* Fleet coverage grows with agent rollout; all analyses must treat `SentAt == null`
(legacy rows, old agents) as "legacy path", never as zero.
# Citations
* `BackendTelemetryUploader.UploadBatchAsync` — header stamping per attempt.
* `IngestTelemetryFunction.ParseSendTimeHeader` + `IngestTelemetryFunctionTests` — parse
contract incl. the no-upper-clamp decision.
* `EventIngestProcessor.StampServerFields` + `IngestCriticalPathTests` — server-side
stamping, forged per-event values overridden.
* `RuleEngine.ConditionEvaluators.EvaluateClockSkewCondition` + `RuleEngineClockSkewTests`
— the two measurement paths (the backlog-alias test pair pins why SentAt exists).
* `tasks/todo.md` P14 (2026-08-21) — decision trail incl. why the tripwire stays as is.
@@ -1,102 +0,0 @@
---
type: Concept
title: ServerActions & On-Demand Diagnostics Collection
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.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs
tags:
- backend
- agent
- web
- server-actions
- diagnostics
- authorization
timestamp: 2026-07-24T00:00:00+02:00
---
# Schema
**Channel.** A `ServerAction` (`Type`, `Reason`, `RuleId`, `Params`, `QueuedAt`) is queued onto
`Sessions.PendingActionsJson` by server-side writers — the admin endpoint
`POST /api/sessions/{sessionId}/actions`, the RuleEngine, maintenance — and delivered
piggy-backed on the agent's next ingest response. No polling channel exists; delivery latency
equals the agent's telemetry cadence (seconds to ~1 minute during enrollment). Delivery is
at-least-once: the agent's `ServerActionDispatcher` dedups on `Type|RuleId|QueuedAt` and
processes a batch sequentially in queue order. Unknown types are logged and skipped, so new
types roll out server-first. Every action emits `server_action_received` and then
`server_action_executed` or `server_action_failed` into the session timeline.
**Authorization (two layers).** The route sits on `EndpointPolicy.TenantAdminOrOperator`
(tenant Admin, tenant Operator, or Global Admin — no Viewer, no Global Reader: it is a write
tier). Because the route serves multiple action kinds, `QueueSessionActionFunction`
re-gates per type via `IsTypeAllowedForCaller`: Operators may queue only
`request_diagnostics`; `terminate_session` and `rotate_config` require
`RequestContext.IsTenantAdmin || IsGlobalAdmin` (403 otherwise). Any route on this tier with
a multi-kind body MUST follow this same in-function re-gating pattern.
**On-demand collection (`request_diagnostics`).** The dispatcher calls
`DiagnosticsPackageService.CreateAndUploadAsync(enrollmentSucceeded: false, suffix:
"server-requested")` — producing `AgentDiagnostics-{sessionId}-{timestamp}-server-requested.zip`.
Passing `enrollmentSucceeded: false` is what makes on-demand collection work under
`DiagnosticsUploadMode=OnFailure`; only `Off` (or an unconfigured destination) blocks it,
surfacing as `server_action_failed`. The `diagnostics_uploaded` event stamps
`DiagnosticsBlobName` + `Destination` onto the Sessions row (latest wins — an on-demand name
is later replaced by the terminal package), and the SignalR `sessionUpdate` delta carries the
field, so the portal's Download button flips live.
**Portal flow (Collect Logs, session detail header).** The button is always rendered; the
disabled states carry tooltips (inactive session, cross-tenant view, missing role, diagnostics
not configured) as a deliberate nudge. Members below Admin cannot read the tenant config, so
the member-readable feature-flags endpoint exposes `diagnosticsUploadConfigured`
(`mode != Off && (customer SAS present || destination Hosted)`). Progress tracking is
sequence-based (backend-assigned, monotonic), never timestamp-based — agent clock skew and
at-least-once redelivery of older actions cannot produce false matches
(`collectLogsLogic.ts`). Cross-tenant views are gated off because `TenantHelper.GetTenantId`
resolves the tenant strictly from the caller's JWT.
**Admin quick-config.** For an unconfigured tenant, a Tenant Admin's click opens a dialog
that switches the tenant to `Destination=Hosted`, `Mode=OnFailure` (GET full config → patch
exactly those two fields → PUT verbatim), then queues `rotate_config` **before**
`request_diagnostics` — the dispatcher's sequential ordering guarantees the agent refetches
the now-enabled config (the `RemoteConfigMerger` applies diagnostics fields live, remote wins)
before building the package. This works against the already-deployed agent fleet; a
"one-time override" variant would not, because the `mode=Off` check is client-side in
`CreateAndUploadAsync` and the deployed handler reads no params.
**Offboarding cascade.** Latest-wins on `DiagnosticsBlobName` means a session can leave more
hosted blobs behind than the deletion manifest references. The cascade's Hosted branch
therefore sweeps the canonical prefix `{tenantId}/AgentDiagnostics-{sessionId}-`
(`HostedDiagnosticsBlobService.DeleteBySessionPrefixAsync`) plus the manifest-named blob as
belt-and-braces; a non-GUID legacy `SessionId` degrades to the single-name delete instead of
poisoning the cascade. The CustomerSas branch is unchanged (delete only with the `d`
permission; the customer's lifecycle rules stay authoritative).
# Examples
Queue an on-demand collection (Admin or Operator, own tenant):
```
POST /api/sessions/{sessionId}/actions
{ "type": "request_diagnostics", "reason": "On-demand log collection from portal" }
→ 202 { "success": true, "queuedAt": "..." }
```
Operator queueing an admin-only type:
```
POST /api/sessions/{sessionId}/actions
{ "type": "terminate_session" }
→ 403 { "message": "Action type 'terminate_session' requires the Tenant Admin role" }
```
Timeline for a successful round trip: `server_action_received` → `server_action_executed`
(with `blobName`) → `diagnostics_uploaded` → Sessions row + SignalR delta carry the new
`DiagnosticsBlobName`.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs` — allowed types, per-type caller gate.
* `src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs` — `TenantAdminOrOperator` tier definition.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs` — dedup, sequential dispatch, telemetry contract.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — mode gates, archive build, suffix.
* `src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs` — hosted prefix sweep.
* `src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts` — button gating matrix, sequence-based progress evaluation.
-122
View File
@@ -1,122 +0,0 @@
---
type: Concept
title: Session Annotations — Role-Separated Human Verdicts
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
tags:
- backend
- web
- mcp
- annotations
- authorization
- rule-quality
timestamp: 2026-08-11T00:00:00+02:00
---
# Schema
**Table.** `SessionAnnotations`: PartitionKey = `tenantId`, RowKey = `{sessionId}_{lane}` with
lane ∈ `operator | tenantadmin | globaladmin` (a closed set — `AnnotationLanes.All`). One row per
session + lane, edited in place; per-session reads use the RK range `ge '{sid}_' / lt '{sid}_~'`.
Serialization lives only in `TableSessionAnnotationRepository.StoreAnnotation`/`MapAnnotation`
(round-trip-pinned; corrupt `RuleIdsJson` degrades to an empty list on read).
**Row.** `Verdict` (nullable: `root_cause_confirmed | analysis_wrong | different_problem |
inconclusive`), `Note` (nullable, ≤ 4096 chars), `AuthorUpn`/`AuthorDisplayName` (last editor),
`CreatedByUpn`/`CreatedAtUtc` (first writer, immutable across edits), `UpdatedAtUtc`, and
`RuleIdsJson` — a **snapshot of the rule ids that had fired for the session at write time**
(from `IRuleRepository.GetRuleResultsAsync`, fail-soft to empty). The snapshot is what turns an
annotation into a rule-quality label without a join: filtering annotations by `ruleId` + `verdict`
yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.
**Verdict + note are independently optional**; a PUT with both null **deletes** the lane row
(there is no DELETE route). Author identity is always stamped server-side from the JWT
(`TenantHelper.GetUserDisplayName` + `RequestContext.UserPrincipalName`) — a body-supplied
author never wins (same anti-spoof contract as the gather-rule PUT-upserts).
# Authorization
Routes (all in `EndpointAccessPolicyCatalog`):
| Route | Policy | Notes |
| --- | --- | --- |
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
`operator` → tenant Operator or tenant Admin; `tenantadmin` → tenant Admin; `globaladmin` →
Global Admin only. **Own-tenant binding:** the session's tenant is resolved *before* the gate
(GA cross-tenant fallback via `FindSessionTenantIdAsync`), and tenant-role inputs are zeroed
when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of
their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a
GA writes exactly the `globaladmin` lane (the platform labeling flow).
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
admins). Both list endpoints enforce the same rule server-side via the repository's
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
members of that tenant.
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
in the tenant-visible audit log — same convention as GA session-report submissions).
# Lifecycle
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
steps so the `sessionAnnotations` count covers all lanes.
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
expensive data on the platform to lose.
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
raw `list_tables`/`query_table` tools.
# Surfaces
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
count + verdict-pill summary header.
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
middleware-validated `TargetTenantId`, and for callers without global scope the repository
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
`tenantId`.
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
applied server-side but client-of-Azure (substring on the JSON column), so the backend
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
never consume page budget.
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
Safe because report blobs are readable only through the GA-only session-reports routes, and
valuable because the ZIP outlives the session's retention/delete.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
@@ -1,140 +0,0 @@
---
type: Concept
title: Silent-Session Classification (Sweep, Max-Lifetime, Retro-Reconcile)
description: How a session whose agent went silent gets its honest status — the two-stage maintenance sweep (2h Stalled marker, 5h timeout classification), the EnrollmentTimeoutClassifier rule order incl. the WhiteGlove Part-2 and self-deploying gates, which ingest paths share the classifier, and the admin retro-reconcile modes.
resource: src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
tags:
- backend
- maintenance
- session-status
- classification
- whiteglove
- self-deploying
timestamp: 2026-08-27T20:00:00+02:00
---
# Silent-Session Classification
A session only ever becomes terminal through evidence. When the agent stops reporting
(power-off, reboot without relaunch, Wi-Fi never re-associating, max-lifetime watchdog),
the backend must decide the status from what it last saw — it never hard-fails a session
for being silent. One pure function holds that decision,
`EnrollmentTimeoutClassifier.ClassifyTimedOutSession`, and every silent-session path calls it
so a session gets the same verdict regardless of which path noticed the silence first.
# Schema
## Inputs (`EspProvisioningRollup` + session flags)
`ExtractRollup(events)` distills the event stream into facts: Device/Account Setup
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
`SkipUserEsp`, RealmJoin detection/resolution, `HasAppInstallFailure` (any
`app_install_failed`), and `HasAgentMaxLifetimeTimeout` (either max-lifetime shape:
`enrollment_failed`/`agent_timeout` or `agent_shutting_down`/`max_lifetime` — the agent is
provably gone). Session-row flags are passed alongside:
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
## Rule order (first match wins)
| # | Condition | Verdict |
| --- | --- | --- |
| 1 | `HasExplicitFailure` | Failed |
| 1b | `IsWhiteGloveAwaitingUser`: pre-provisioned + resumed + Device Setup done + **no** user evidence since resume + no explicit failure/enrollment_complete | AwaitingUser within grace; Succeeded (honest WG reason) past grace |
| 1c | `IsSelfDeployingProvisioned`: self-deploying profile + Device Setup done + no explicit failure | **Succeeded** (reconcile) — no grace, no AwaitingUser: the profile has no user phase |
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
| 5 | Device Setup done + within grace + **no** max-lifetime watchdog observed | AwaitingUser |
| 5a | Device Setup done + past grace (or watchdog observed) + `DesktopArrived` + no `app_install_failed` | **Succeeded** — completed (assumed), `r5_assumed` |
| 5b | Device Setup done, otherwise past grace (or watchdog observed) | Incomplete |
| 6 | otherwise | Incomplete |
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
measured from `ResumedAt ?? StartedAt`. Every Succeeded-reconcile reason carries the
silence-transparency clause (`AppendReconcileTiming`: last agent contact, silence, verdict time).
## Why rule 1c exists (kiosk tenant audit 2026-08-23)
On a self-deploying profile Device ESP all-succeeded IS the enrollment's end; the agent's
own SelfDeploying terminal only adds a 5-min confirmation window
(`DecisionEngine.SelfDeploying.s_deviceOnlyEspDetectionWindow`). Tenant `aebdce78` (school,
Wi-Fi ThinkBooks, a language-change app forcing a reboot right after Device ESP) lost that
window on 836 of ~970 sessions in one week: the agent died 0–4 min after
`DeviceSetup fallback_confirmed`, the reboot relaunch never registered (30s registration
budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
"awaiting user / Account Setup 0/5" → Incomplete 51h later. Nobody signs in on these devices
(prepared during the holidays, boxed, in service) — AwaitingUser was factually wrong and
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
and retries 6× (~62s backoff) instead of 5× (~30s). Measured 2026-08-27 (builds
2.0.1428/1429, both carrying the fix): the reconcile share did not move, and the backend saw
no registration attempt from any relaunch — no success, no 4xx, zero `register:superseded`
in the tenant — so the relaunch never reaches the network at all (device powered off after
ESP, or the relaunch never runs). The sweep reconcile is the permanent completion path for
this profile, not a stopgap.
## Callers (all pass both gate inputs)
| Path | File | Notes |
| --- | --- | --- |
| Sweep stage 1 (2h agent-silent marker) | `MaintenanceService.MarkStalledSessionsAsTimedOutAsync` | Default: InProgress → Stalled. Gates run BEFORE the Stalled write: `TryMarkWhiteGloveAwaitingUserAsync` (AwaitingUser) and `TryReconcileSelfDeployingAsync` (Succeeded). Only flagged sessions pay the event read. |
| Sweep stage 2 (SessionTimeoutHours, 5h default) | same | InProgress/Stalled/AwaitingUser past the window and past the 2h silence cutoff → full classification; AwaitingUser within grace is skipped without an event read. |
| Agent max-lifetime (`enrollment_failed`/max_lifetime, `agent_shutting_down`/max_lifetime) | `EventIngestProcessor.ApplyMaxLifetimeVerdictAsync` | Watchdog = "agent stopped waiting", not a verdict; classified from evidence. Pending sessions exempt. |
| Late-telemetry reconcile | `EventIngestProcessor.TryLateTelemetryReconcileAsync` | Heal-only: applies a Succeeded verdict to Incomplete/AwaitingUser rows when straggler events arrive. |
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
## Why rule 5a exists (calibration read 2026-08-27)
The verdict-calibration matrix showed r5-Incomplete devices re-enrolling at 2.6 % within
7 days — the succeeded background (`agent:complete_soft` sits at 2.8 %), not the ~29 %
failure level — consistently across tenants, and half the sampled sessions were healthy
desktops with a clean app record whose agent died before the Hello terminal (some with the
agent's own "AccountSetup treated as complete" synthesis as the literally last event). Past
the full grace, "the user phase is still running" is no longer a possible explanation, so
desktop + zero `app_install_failed` + no explicit failure now reconciles to Succeeded
("completed (assumed)") instead of a red Incomplete. Sessions without a desktop (user never
began sign-in: battery-drained, powered off, max-lifetime) and sessions with any failed app
stay Incomplete — "completed" must not overclaim. Rule 4 remains the IMMEDIATE reconcile
(desktop + Hello proof); 5a only fires where waiting is over. Desktop arrival alone is still
rejected as a live completion signal.
## Closed gap: max-lifetime AwaitingUser (2026-08-27)
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
AwaitingUser verdict from that path could never heal — the calibration correction stream
confirmed it: all five observed `maxlife:r5_awaiting` episodes expired to Incomplete
unhealed, while live-agent `sweep:r5_awaiting` parks upgraded 7 of 26 to Succeeded. Rule 5
therefore skips the grace once `HasAgentMaxLifetimeTimeout` is in the stream and decides the
terminal fork (5a/5b) immediately; the max-lifetime ingest appends its trigger event to the
read stream so the fact is never missed, and late straggler telemetry still heals a terminal
verdict via `TryLateTelemetryReconcileAsync`. WhiteGlove rule 1b deliberately keeps its
park — sealed/boxed devices do heal (the observed `maxlife:r1b_awaiting` episode resolved to
Succeeded).
# Examples
* Session `195593e2` (tenant aebdce78): `DeviceSetup 4/4` + `fallback_confirmed`, `AccountSetup 0/5`
(IME false positive), Hello disabled, SkipUser=True, silent after 13:01 → rule 1c →
Succeeded "self-deploying profile — Device Setup fully provisioned … agent went silent
before confirming completion. Agent last reported 2026-08-21 13:01 UTC …".
* Same event shape on a user-driven profile → rule 5 → AwaitingUser (within grace) /
Incomplete (past grace). Guarded by
`Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts`.
* fairstone WG Part 2 reseal-reboot, technician powers off at logon screen → rule 1b →
AwaitingUser; user unboxes days later → ingest completion heals to Succeeded.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs` — rollup + rule order, `IsWhiteGloveAwaitingUser`, `IsSelfDeployingProvisioned`, reason helpers.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — `MarkStalledSessionsAsTimedOutAsync`, `TryMarkWhiteGloveAwaitingUserAsync`, `TryReconcileSelfDeployingAsync`.
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs` — max-lifetime verdict, late-telemetry reconcile, stall mapping.
* `src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs`, `Functions/Admin/ReclassifyLegacySessionsFunction.cs` — retro modes.
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs` — `GetAgentSilentSessionsAsync`, `GetStalledSessionsAsync`, `GetSelfDeployingSilentSessionsAsync`.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — link wait + 6-attempt registration budget.
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs` — the agent-side 5-min SelfDeploying terminal window.
* `src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs` — rule coverage incl. the 2026-08-23 self-deploying block.
-67
View File
@@ -1,67 +0,0 @@
---
type: Concept
title: Table Schema Sentinel (startup table initialization)
description: Why a cold start no longer issues one CreateTableIfNotExists per registered table - a hash over TableNames.All stored in AdminConfiguration gates the full pass, the SessionsIndex backfill only runs after a full pass and behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
resource: /src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
tags:
- backend
- table-storage
- startup
- maintenance
timestamp: 2026-08-23
---
# Schema
## Problem
`TableInitializerService` (hosted service) called `InitializeTablesAsync` on every process start, which issued `CreateTableIfNotExistsAsync` for every entry of `Constants.TableNames.All` (66 at the time of writing, DoP 8). Each call is a Create POST whose 409 is swallowed - on Flex Consumption with frequent cold starts that was ~9 serial round-trip waves before the first request. It was followed by a SessionsIndex emptiness query whose *error* path returned "empty", so a transient storage error triggered a full Sessions scan with serial upserts - on every scaled-out instance at once.
## Sentinel
* Row `AdminConfiguration` / PK `SchemaSentinel` / RK `tables`, property `TableSchemaHash` (+ `TableCount`, `UpdatedUtc`).
* Value = `ComputeTableSchemaHash(TableNames.All)`: SHA-256 over the ordinal-sorted, newline-joined table names. Derived, never hand-maintained: adding a table to `TableNames.All` changes the hash by construction (`TableLifecycleBucketTests` already forces every constant into `All`).
* `InitializeTablesAsync` (returns `bool fullPassRan`):
1. Point-read the sentinel. Match -> mark initialized, **zero** table calls, return `false`.
2. Missing / stale / unreadable (fresh storage has no AdminConfiguration table yet) -> `EnsureAllTablesAsync` (the old full pass), then upsert the sentinel **only if no table failed**, return `true`. A failed pass leaves `_tablesInitialized = false`, so the next call retries.
* Scale-out: two instances that both see a stale sentinel both run the idempotent pass - harmless.
## SessionsIndex backfill
* Only evaluated when the full pass ran (`fullPassRan == true`): an empty index can only exist on fresh storage, which is exactly the case without a sentinel.
* `IsSessionIndexEmptyAsync` now returns `false` on error (skip), not `true`.
* `TryClaimSessionIndexBackfillAsync`: conditional `AddEntityAsync` on `SchemaSentinel` / `sessionIndexBackfill` - only the winner scans; 409 or any error -> skip. The manual maintenance backfill (`RunManualAsync`) remains the safety net.
## Known gap and repair
A table deleted out-of-band (cleanup skill, portal, tests) is not noticed at startup anymore. `IStorageInitializer.EnsureAllAsync` runs as the first step of the daily timer maintenance (`MaintenanceService.RunAllAsync`), recreating it within 24h. Storage helpers are fail-soft in the meantime.
## Measuring it
`StartupTelemetryService` (hosted service) emits two App Insights metrics on `IHostApplicationLifetime.ApplicationStarted` - i.e. after every hosted service finished `StartAsync`:
* `BackendStartupMs` - process start (`Process.StartTime`) to host ready; includes runtime, DI and all hosted services.
* `BackendTableInitMs` - the `InitializeTablesAsync` slice alone (`TableStorageService.LastInitialization`).
Dimensions: `version`, `commit`, `tableInitFullPass`, `instance`. Metrics bypass adaptive sampling and the worker log level, so they arrive even though Information traces do not. The MCP server logs the same idea as one `{"type":"boot","readyMs":…}` stderr line per start (ContainerAppConsoleLogs_CL, see `.claude/commands/mcp-telemetry.md`).
```kusto
customMetrics
| where name in ("BackendStartupMs", "BackendTableInitMs")
| summarize p50 = percentile(value, 50), p95 = percentile(value, 95), n = count()
by name, version = tostring(customDimensions.version), fullPass = tostring(customDimensions.tableInitFullPass)
```
# Examples
* Deploy that adds a table: first cold start after the deploy runs the full pass once (log: "Table schema sentinel missing or stale"), every later start logs "Table schema sentinel matches (...)".
* Fresh storage account: sentinel read throws TableNotFound -> full pass -> index empty -> one instance claims and backfills.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs` - sentinel, `InitializeTablesAsync`, `EnsureAllTablesAsync`, `TryClaimSessionIndexBackfillAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs` - startup gating
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` - daily `EnsureAllTablesAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/StartupTelemetryService.cs` - `BackendStartupMs` / `BackendTableInitMs`
* `src/McpServer/autopilot-monitor-mcp/src/index.ts` - `{"type":"boot"}` line
* `src/Backend/AutopilotMonitor.Functions.Tests/TableSchemaSentinelTests.cs`, `StartupTelemetryServiceTests.cs`
-89
View File
@@ -1,89 +0,0 @@
---
type: Concept
title: Telemetry Ingest Shaping (App Insights / Log Analytics)
description: What the backend deliberately keeps out of Application Insights and why - one canonical request record (worker copy, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation), expected storage outcomes (404/412/409) and successful SignalR REST calls filtered in the worker, and the query rules that follow.
resource: /src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs
tags:
- backend
- telemetry
- application-insights
- cost
timestamp: 2026-08-23
---
# Telemetry Ingest Shaping
The backend is storage-I/O heavy and runs on the Functions isolated worker, which emits telemetry from TWO processes (host and worker). Left alone, Log Analytics ingestion is dominated by rows nobody reads. This document records what is removed, where, and what that means for queries.
# Schema
## Request telemetry — one canonical record
| Source | Emitted by | Content | Fate |
|---|---|---|---|
| Host HTTP request (`Properties.Source` empty, `Url` set) | Functions host process | Name, duration, status — no tenant/user/correlation | **Dropped at ingestion** by the workspace transformation on `AppRequests` (`infra/appinsights-workspace-transforms.bicep`) |
| Host non-HTTP invocation (`Url` empty) | Functions host process | Timer / queue executions | Kept (the worker middleware never sees them) |
| Worker request (`Properties.Source == 'WorkerMiddleware'`) | `RequestTelemetryMiddleware` | TenantId, UserId, UserRole, CorrelationId, ClientSource, McpToolName, HttpPath | **Kept, unsampled** — the canonical record |
Why two mechanisms:
* The host request item cannot be suppressed from worker code or from `host.json` (`Host.Results` controls log traces, not the request item; verified 2026-06-09). The only lever is a Log Analytics workspace transformation: `source | where tostring(Properties.Source) == 'WorkerMiddleware' or isempty(Url)`.
* `host.json` `samplingSettings.excludedTypes: "Request;Event"` only applies to the host process. The worker's `AddApplicationInsightsTelemetryWorkerService` runs its own adaptive sampling; before 2026-08-23 worker request rows carried `ItemCount` 2–5 on weekdays (≈30 % of individual requests missing, `count()` undercounting by the same share — `sum(ItemCount)` matched the host copy to 1.00 per day). Re-configuring the worker sampling chain did not take effect in the isolated worker (see the L4 note in `Program.cs`). The per-item opt-out does: the middleware sets `SamplingPercentage = 100` on its own `RequestTelemetry`, and the SDK sampling processor passes through every item whose sampling percentage is already set.
## Dependency telemetry — keep only signal
`StorageDependencyFilterProcessor` (worker pipeline) drops:
* successful Azure Storage calls (Table/Queue/Blob) in both shapes — the HTTP shape (`Azure table`, target `*.table.core.windows.net`) and the Azure SDK ActivitySource shape (`InProc | Microsoft.Tables`, `InProc | Microsoft.Storage`);
* storage calls with an **expected outcome**: `404` (point-read miss — EventTypeIndex, BlockedDevices lookups), `412` (ETag precondition), `409` (idempotent insert conflict). HTTP-shape rows carry the code in `ResultCode`; InProc rows only in `Properties["Error"]` (`Status: 404 (`). Before this filter these were 100 % of the remaining "failed" storage rows (~540 MB/week, two rows per call);
* successful Azure SignalR Service REST calls the worker makes itself (`*.service.signalr.net`).
The bulk of SignalR REST traffic (group add/remove per connection, ~470 MB/week) is emitted by the **host** process for the SignalR output binding (`SDKVersion rdddsc:*`) and never passes the worker pipeline — live-verified after the 2026-08-23 deploy (worker rows: 0, host rows: ~560/h). Those are removed by the second data flow of the same workspace transformation: `AppDependencies | where not(Success == true and Target endswith '.service.signalr.net')`.
Everything else passes: storage 429/5xx/auth/timeouts, failed SignalR calls, all HTTP/Graph/NVD/MSRC dependencies, the worker's own `InProc Invoke` span, and every non-dependency item.
## Measured baseline (7 days before the change, `_BilledSize`, `autopilotmonitor-api-eu`)
| Table | Rows | MB | After |
|---|---|---|---|
| AppDependencies | 976k | 1248 | ≈ 240 (SignalR + expected-outcome rows gone) |
| AppRequests host copy | 274k | 323 | ≈ 0 (only timer/queue rows) |
| AppRequests worker copy | 189k | 208 | ≈ 300 (now 100 % of requests) |
| AppTraces | 142k | 137 | unchanged |
| AppEvents | 6k | 4 | unchanged — not worth touching |
Net ≈ −1.3 GB/week with MORE usable data (every request now has tenant context).
# Examples
Counting requests (exact since 2026-08-23; for older data replace `count()` with `sum(itemCount)`):
```kql
requests
| where customDimensions.Source == 'WorkerMiddleware'
| summarize total=count(), failed=countif(success == false) by name
```
Real storage trouble only:
```kql
dependencies
| where type startswith 'InProc | Microsoft.Tables' or target endswith '.table.core.windows.net'
| summarize count() by resultCode, tostring(customDimensions.Error)
```
Post-deploy verification of the per-item sampling bypass (must be `ItemCount == 1` only):
```kql
AppRequests
| where TimeGenerated > ago(1h) and tostring(Properties.Source) == 'WorkerMiddleware'
| summarize rows=count() by ItemCount
```
# Citations
* `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `SamplingPercentage = 100` before `TrackRequest`.
* `src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs` + `StorageDependencyFilterProcessorTests.cs` — the filter contract.
* `src/Backend/AutopilotMonitor.Functions/Program.cs` — the failed L4 worker-sampling attempt and why it must not be retried.
* `infra/appinsights-workspace-transforms.bicep` — the AppRequests transformation DCR, apply/verify/rollback commands.
* `.claude/commands/backend-logs.md`, `session-debug.md` — query conventions.
@@ -1,139 +0,0 @@
---
type: concept
title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
resource: backend
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
timestamp: 2026-08-23
---
# Schema
## Activation
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
30 s negative cache).
Two activation routes, one shared implementation
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
Typical latency: ~1 minute after signup.
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
arbitrates concurrent activations; exactly one caller runs the side effects
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
## The welcome-mail race
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
same table) — the only reliable address: it is typed by the user on the activation-pending
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
`ContactEmail` is merely seeded from the notification email.
That makes activation and address entry concurrent writers. With auto-approve, activation
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
approval path found no address and the mail was silently skipped).
**Arbitration — write-then-read on both sides plus a send-once marker:**
* The approval path writes the `approved` row, then reads the address.
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
activation for exactly this window).
* Since both do write-then-read, at least one sees both halves and calls
`TenantApprovalService.TrySendWelcomeEmailAsync`.
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
sends: at most one wins. The marker is consumed strictly **after** the address check —
consuming it without an address would permanently suppress the mail.
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
## Finding the tenant behind an address
The provider's delivery log names only the recipient — never the tenant it belonged to. The
reverse lookup is therefore part of the tenant console: its search matches `tenantId`,
`domainName`, `ContactEmail` **and** the notification address. 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 a different table than the tenant configs the list is built from, so the
console loads them as a map from `GET /api/preview/notification-emails` (GA + read-only Global
Reader, `excludeDelegated`) — one cross-partition scan of `RowKey eq 'notification-email'`
instead of a point read per tenant. Keys are lowercased on both sides of the join: the address
rows are written from the JWT `tid` (lowercase) while a config PartitionKey's casing is not
guaranteed. Stored addresses keep the casing the user typed, so matching is case-insensitive.
The map is **best-effort** in the portal: it powers only the search-by-address, so a failed read
degrades that one capability instead of taking the tenant list down. The addresses are not
rendered in the list — they are a search key, and stay visible in the tenant editor.
## Mail transport
Both transactional mails (welcome, post-offboarding farewell) go through the
provider-neutral `EmailService` (`IEmailService` for the welcome path,
`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation
detail confined to one private transport method plus the `Email:*` configuration
section — a provider swap must not rename classes, DI registrations or callers.
* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}`
(`messages/send`), API key in the JSON body, typed `HttpClient` with the shared
`ResiliencePolicies.Notification` retry policy (15 s timeout).
* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every
send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress`
(default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`).
* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success;
`rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning.
Never throws — both callers rely on that fail-soft contract.
* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider
receives only the recipient address and the tenant domain, which is exactly the claim on
the privacy page and the trust data-flows page. Changing tracking = changing those pages.
## Template overrides (operator-editable)
Global Admins can replace either HTML body without a deployment (portal: Settings →
Email Templates). `EmailTemplateService` resolves the body for every send: the stored
override wins, otherwise the built-in `EmailTemplates.*`. Overrides live in the
`PreviewConfig` table (partition `EmailTemplates`, row key `welcome` | `farewell`,
properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on save/reset.
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
matching the built-ins. Subjects are not editable (pinned constants).
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
read path is not — a broken store surfaces as an error instead of silently showing the
built-in.
* Endpoints (all GlobalAdminOnly): `GET|PUT|DELETE /api/global/email-templates/{kind}` and
`POST /api/global/email-templates/{kind}/test`. The test send goes to the caller's tenant
contact address (activation notification email → `ContactEmail` → caller UPN) with the
caller's tenant domain, optionally with an unsaved draft body; provider tag `test`.
# Examples
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
notification-email save path"; user saves address at T+69 s → save path sees approved
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
* Address saved before manual approve (classic shape): approval path finds the address,
wins the marker, sends; a later re-save of the same address does not re-send.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
* `src/Web/autopilot-monitor-web/app/admin/components/tenantSearch.ts` — the console's match rules (searchable fields, quoted exact mode, lowercased join)
-144
View File
@@ -1,144 +0,0 @@
---
type: Concept
title: Enrollment Time Attribution (F1)
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.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs
tags:
- backend
- metrics
- time-attribution
- insights
timestamp: 2026-07-26T20:00:00+02:00
---
# Enrollment Time Attribution (F1)
Answers "where did the 47 minutes go?" per session and per fleet. Every number is
traceable to observed events; a missing signal produces "unknown" or an explicit
unattributed remainder — never a guess, and the partition is never normalized to 100 %.
# Schema
## Invariant
For every computed breakdown:
```
sum(span seconds) + UnattributedSeconds == WallClockSeconds == session DurationSeconds
```
`DurationSeconds` is the authoritative wall clock — for WhiteGlove it is part 1 + part 2
WITHOUT the pause, and re-terminal stamps move `CompletedAt`, so `CompletedAt − StartedAt`
is never used (it diverges in ~25 % of terminal sessions). Span seconds are floored;
sub-second dust lands in the unattributed remainder.
## Observation windows
Windows are end-anchored on authoritative timestamps and constructed so their lengths sum
exactly to `DurationSeconds`:
* Non-WhiteGlove: one window `[CompletedAt − DurationSeconds, CompletedAt]`.
* WhiteGlove (ONE session row, two windows): part 2 = `[ResumedAt, CompletedAt]`; part 1 =
`[part1End − (DurationSeconds − part2), part1End]` where `part1End` is anchored on the
`whiteglove_part1_complete` event. The pause between the windows is never attributed.
Fallback anchors set the `WhiteGloveAnchorsIncomplete` quality flag.
## Segments
Spans are sliced at phase-declaration events (only declaration events carry
`Phase != Unknown`) plus `desktop_arrived`, in canonical Sequence order; anchors whose
timestamp runs backward are dropped (`ClockSkewDropped`).
| EnrollmentPhase | Segment key |
|---|---|
| Start / DevicePreparation / DeviceSetup | `device_prep` |
| AppsDevice | `esp_apps` |
| AccountSetup / FinalizingSetup | `identity_hello` |
| AppsUser | `user_esp` |
| Complete, `desktop_arrived` event | `desktop_handoff` |
| Failed | attribution ends — tail stays unattributed |
## Quality flags (AttributionVersion 2)
Duration-critical flags (`ClockSkewDropped`, `PartialObservation`,
`WhiteGloveAnchorsIncomplete`, `PriorEnrollmentResidue`) exclude a breakdown from fleet
segment statistics with a disclosed count; the blocking-set flags (`BlockingSetUnknown`,
`BlockingSetTruncated`) only limit per-app evidence and never gate fleet stats.
`PriorEnrollmentResidue` (v2) fires on `historic_ime_replay_detected`: the IME log on disk
predates the enrollment, so the device was re-enrolled without a wipe — pre-installed apps
complete as instant detections and pull the phase anchors ahead of the real ESP page
(session f475e697: AccountSetup anchor 6.5 min early, 457 s misattributed to
`identity_hello`). `registry_app_baseline` successes are deliberately NOT a trigger — they
are by-design normal for Windows Device Preparation (DPP Batch-1 apps install before the
agent exists) and would starve that class's aggregates.
## ESP-blocking apps (positive evidence only)
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
event, covering IME retries) — never the agent payload timing, which freezes at the first
terminal transition. Blocking membership joins the app id against the session's latest
list-carrying `esp_config_detected` emission: listed ⇒ blocking; absent ⇒ unknown, never
false (`BlockingSetUnknown` / `BlockingSetTruncated` flags). Critical-path occupancy =
overlap-merged union of blocking intervals clipped to the `esp_apps` spans;
`EspAppsOccupancySeconds` is null (unknown) when no lists were observed.
The fleet what-if bound per app X is `max(0, cpEnd − cpEndWithoutX)` (latest interval end
with vs. without X; the idle gap before X counts — the ESP also waits for X to start).
It is an upper bound by construction; every consumer says "up to", never "you will save".
## Reboots
`system_reboot_detected` is detection-time-stamped by the NEXT agent run; the real boot
moment is its `lastBootUtc` payload. Reboot spans are therefore the event-stream gap
bracketing `lastBootUtc`, deduped per boot, clipped to the observation windows, and carried
as a cross-cutting annotation (they overlap the segment they started in — never a slice of
the partition).
## Persistence & compute
* `SessionTimeBreakdowns` (PK = TenantId, RK = SessionId): spans, reboot spans, blocking
intervals (top 20 + uncapped count), quality flags, `AttributionVersion`. Written once at
the session-terminal single-writer seam (`TableSessionRepository.UpdateSessionStatusAsync`,
Succeeded/Failed only — Incomplete deliberately has no duration). Deleted with its session
(deletion-manifest step) and on tenant offboarding.
* Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds
late-terminating sessions into their StartedAt-date aggregates, and enforces version
purity — a stale-version row whose events aged out counts as missing, never mixed.
Reads the tick's **shared window scan** (`MaintenanceService.LoadSweepWindowSessionsAsync`,
one projected 35-day cross-tenant drain via `MaintenanceSweepSessionProjection` that
also feeds the device-journey and verdict-calibration sweeps) — the StartedAt-only
filter is a full-table read in Table Storage, so it is done once per tick, not once per
sweep; `MaintenanceSweepProjectionEquivalenceTests` pins the column set.
* `TimeAttributionAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}|{class}` and
`rolling30|{class}`): per enrollment class (`user_driven`, `whiteglove`, `self_deploying`,
`device_preparation` — never mixed) median/p75/p90 per segment over the fixed six-segment
stack (incl. `unattributed`; a session without a segment contributes 0), plus top blocking
apps (≥5 sessions per app row). Clean/flagged/missing counts are part of every row; rows
exist below the ≥20 UI gate on purpose (the UI needs the n for "insufficient data (n=…)").
Daily rows retain 180 days; `rolling30` rows are refreshed whole (a median of per-day
medians is not the range median — the rolling rows are the honest range statistics).
## Surfaces
* `GET api/sessions/{sessionId}/time-attribution` (MemberRead + tenant query param) —
breakdown row or `breakdown: null` (pre-feature / non-terminal / Incomplete = no lane).
* `GET api/metrics/time-attribution` (MemberRead) / `GET api/global/metrics/time-attribution`
(GlobalReadOrAdmin, optional `tenantId`) — `{ windowDays, classes (rolling), daily }`.
* Web: attribution lane under the session-detail phase timeline; "Time attribution" section
on Fleet Health. MCP: `get_time_attribution` (sessionId mode / fleet mode).
# Examples
A 30-minute user-driven session: `device_prep` 5m → `esp_apps` 10m (occupancy 4m by one
blocking app) → `identity_hello` 8m → `user_esp` 5m → `desktop_handoff` 2m, unattributed 0.
A WhiteGlove session with a 2-day pause reports wall clock = part1 + part2 only; the resume
gap before the first part-2 phase declaration is unattributed, not guessed.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 2).
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
* `tasks/insights-expansion-spec.md` — F1 specification and source-data audit (§0.5).
-176
View File
@@ -1,176 +0,0 @@
---
type: Concept
title: Verdict Calibration (classifier thermometer)
description: How every session status write declares its VerdictPath, how overriding a verdict preserves it as PriorStatus/PriorVerdictPath, how the daily calibration aggregate counts paths with the re-enrollment proxy and the correction stream, and how the drift radar (share / silence / evidence-gap) turns that into operator-only ops events.
resource: src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.VerdictCalibration.cs
tags:
- backend
- sessions
- classifier
- calibration
- regression-radar
timestamp: 2026-08-27T20:00:00+02:00
---
# Verdict Calibration (classifier thermometer)
A session's terminal status is written by ~20 code paths: the agent's own terminal events,
the silence classifier (`EnrollmentTimeoutClassifier`, rules 1–6) reached via the
maintenance sweep / the agent's max-lifetime shutdown / late-telemetry heal / retro
reclassification, the analyze-rule engine, admin marks and session superseding. Verdict
calibration makes every one of those paths **countable**, tracks how often a path's verdict
was later **overridden**, attaches the delayed **re-enrollment proxy**, and raises an
operator-only alarm when a path's share **drifts**. It is deterministic, fact-based tuning
input for the rules — explicitly not a learned model, and never a customer-facing feature.
# Schema
## VerdictPath on the session row
`SessionSummary.VerdictPath` (`VerdictPaths` vocabulary, `origin:detail`, append-only) is
stamped by **every** status write — `UpdateSessionStatusAsync` takes it as a required
parameter, `StoreSessionAsync` stamps `register:new` / `register:whiteglove_resume` and
preserves an existing path through the re-registration Replace, `SetSessionPreProvisionedAsync`
requires it whenever it writes a status. Unlike `FailureSource` (persisted only on `Failed`),
the path survives for Incomplete, AwaitingUser, Stalled and Pending.
| Origin | Paths |
| --- | --- |
| `agent` | `complete`, `complete_soft` (Continue-Anyway), `failed`, `esp_failure_fallback`, `gather_complete`, `whiteglove_pending`, `whiteglove_resumed`, `stall_probe`, `stall_heal` |
| `ingest` | `wg_awaiting` (stall probe mapped to WhiteGlove AwaitingUser) |
| `sweep` | `stalled`, `wg_awaiting`, `sd_reconcile`, `r{rule}` (stage-2 classifier) |
| `maxlife` / `late` / `retro` | `r{rule}` (classifier via max-lifetime shutdown / late-telemetry heal / retro reclassification); `retro:sd_reconcile`, `retro:superseded` |
| `register` | `new`, `whiteglove_resume`, `superseded` |
| `rule` | `rule:{ruleId}` (analyze rule with MarkSessionAsFailed) |
| `manual` | `failed`, `succeeded` |
| `legacy` | read-side derivation only (`legacy:{rule}` when the rule is recoverable from the reason literal, `legacy:unknown` otherwise) — never written |
Classifier rule ids (`ClassifierRules`): `r1`, `r1b_awaiting`, `r1b_succeeded`, `r1c`, `r2`,
`r3`, `r4`, `r5_awaiting`, `r5_incomplete`, `r6`. `ClassifyTimedOutSession` returns
`(Status, Reason, Rule)` so each `return` names its rule.
## PriorStatus / PriorVerdictPath — the correction stream
When a write replaces a prior **verdict** (existing status ∈ Succeeded/Failed/Incomplete/
AwaitingUser with a stamped path, incoming status different), the storage seam preserves the
pair (`TableStorageService.ComputePriorVerdict`, both the ETag path and the force path). A
stall marker healing to InProgress, a same-status refresh, or an unstamped legacy row never
sets it. Admin marks, late agent completions and retro reclassifications all flow through it.
## Read-side derivation for pre-instrumentation rows
`VerdictPathDerivation.Derive(session)` returns the stamped path verbatim, otherwise derives
from `AdminMarkedAction`, `FailureSource`, `ReconcileReason` and `FailureReason` using the
writers' fixed literals (classifier rule prefixes, the `max-lifetime watchdog` / `Retro-
reclassified` suffixes, `Rule: `, `Superseded by session`, `Late completion report received`).
Unambiguous rows get the real path (`agent:complete`, `manual:failed`, `rule:…`,
`maxlife:r6`), ambiguous-but-rule-known rows `legacy:{rule}`, the rest `legacy:unknown`. No
mass backfill: the aggregate counts derived rows with `DerivedCount` so the matrix shows
history while flagging its weaker evidence. Tests pin the literals against the classifier
output so a wording change fails loudly.
## Daily aggregate — `VerdictCalibrationAggregates`
PK = `TenantId` | `"global"`, RK = `yyyy-MM-dd` (session **StartedAt** date, like every daily
aggregate). `VerdictCalibrationDailyAggregate { Version, SessionCount, TerminalSessionCount,
Buckets[], ComputedAt }` with one bucket per (VerdictPath, Status):
| Field | Meaning |
| --- | --- |
| `Count` / `DerivedCount` | sessions currently on the path; of those, derived read-side |
| `Eligible7d` | terminal sessions whose end (`CompletedAt ?? LastEventAt ?? StartedAt`) lies ≥ 7 d before compute time |
| `ReEnrolled7d` | of the eligible: the device's `DeviceHistories` chain shows the next terminal session starting < 7 d after this one's end |
| `OverriddenByAdmin` / `OverriddenByLateCompletion` / `OverriddenOther` | sessions whose **PriorVerdictPath** is this bucket — attributed to the path that was overridden, so a bucket can carry overrides with `Count = 0` |
`MaintenanceService.SweepVerdictCalibrationAsync` (2-hourly, after the device-journey sweep
so the chains are fresh; manual maintenance parity; kill switch
`VerdictCalibrationSweepDisabled`) slices the tick's shared projected window scan
(`LoadSweepWindowSessionsAsync` — one 35-day cross-tenant drain feeds the time-attribution,
device-journey and calibration sweeps plus the radar's tenant discovery; the StartedAt-only
filter is a full-table read, so never per sweep), recomputes the rolling 30-day window whole (Replace),
deletes stale date rows it did not regenerate, and the unbounded-tables cleanup applies the
180-day retention. Sessions inside a deletion cascade are skipped; the table is regenerable
and is wiped with the tenant on offboarding.
## Read surfaces
* `GET global/metrics/verdict-calibration?days=&tenantId=` (GlobalReadOrAdmin,
TenantScoping.QueryParam; `VerdictCalibrationResponse.Build`): per path — count, share of
all window sessions, derived count, eligible/re-enrolled with `reEnrollRatePct` **null below
20 eligible**, the three override counters, a today-anchored 7d-vs-28d share trend
(`window7`, `baseline28`, `lift` null without baseline) — plus totals, the trend basis and
the active `alerts[]`. Empty but well-formed before the first sweep.
* MCP `get_verdict_calibration` (platform scope: GA + Global Reader). Same endpoint, LLM-shaped in
`verdict-calibration-shape.ts`: per-row `window7.sessions` / `baseline28.sessions` are dropped (the
denominators live once in `trend`), `reEnrollRatePct` / `lift` are always present (`null` = withheld),
and `minSharePct` / `top` trim the one-session tail — rows carrying overrides are never trimmed and
everything dropped is reported in `omitted`. The wire shape the admin page consumes is untouched.
* Portal: Global Admin → Metrics → Verdict Calibration (`/admin/metrics/verdict-calibration`):
matrix grouped by origin, status pill, `derived n` gray pill, trend glyph (arrow only from
5 window hits and lift ≥ 2 / ≤ 0.5), re-enrollment rate or `— (n=…)`, overrides
`admin / late / other`, `↑ Drift` pill on rows with an active episode, and the active
episodes list.
## Drift radar — `VerdictCalibrationRadar`
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
prior 28 days, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.RateIncreaseSeparated`), with
stricter floors since the first-month tuning (2026-08-27): **≥ 10 window hits** (verdict
shares on single-digit counts are noise — a 3-hit episode survived as such), and a per-path
share regression additionally needs **≥ 5 baseline hits** — a path with no established
baseline is new (or renamed) vocabulary, not a regression. Anchored on yesterday, over every
partition with rows (each tenant and `global`):
| Kind | Signal | Re-arm |
| --- | --- | --- |
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded per-path) | share < 1.5× baseline, the path stops occurring, the path is no longer eligible, or the current numbers fall below the fire floors |
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one). Rule-shaped `legacy:*` paths count into BOTH sums (window and baseline): they were the same backend decisions before the 2026-08-23 instrumentation, and without that continuity the group had a near-zero baseline by construction and fired a lift-124 rollout artifact | share < 1.5× baseline or hits stop |
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts (stamped rule paths plus rule-shaped `legacy:*`, same continuity), ≥ 20 verdicts | < 15 % or < 20 verdicts |
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
retention re-arms long burners). A new episode fires **one** `VerdictCalibrationDrift` ops
event (category Maintenance, Warning; dual-registered in the web `OPS_EVENT_TYPES`) — there
is deliberately **no tenant bell**. Per-path findings on a tenant partition get the on-fire
dimension correlation (`RuleRegressionRadar.ComputeDimensionConcentration` over the window's
sessions whose derived path matches), reported with the shared wording contract "correlated,
not necessarily causal"; group kinds and the global partition carry no dimension claim.
Kill switch `VerdictCalibrationRadarDisabled`.
# Examples
* **Reading the thermometer (prod, 2026-08-23 hand analysis that motivated this):**
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
cautious; the matrix now shows this per rule instead of per status.
* **First real calibration read (2026-08-27, four days instrumented):** the per-rule and
per-tenant split resolved that hand analysis — `legacy:r5_incomplete` re-enrolled at 2.6 %
(the `agent:complete_soft` background), spread across tenants, while `legacy:r6`'s 29 %
was concentrated in one tenant that disciplinedly redoes failed enrollments (48.5 % there,
6.7 % elsewhere — so r6 stays untouched, it correctly flags that tenant). Consequences
shipped the same day: rule 5a "completed (assumed)" (see
[Silent-Session Classification](silent-session-classification.md)), the max-lifetime grace
skip, and the radar floors + legacy group continuity above — the first production month
had produced nine standing alerts, every one an instrumentation-rollout artifact.
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
Four admin marks since launch were invisible before — now each one counts against the path
it corrected.
* **Evidence gap:** a tenant whose agent build stopped emitting the Device Setup rollup line
drives its sweep verdicts into `r6`; the absolute gate fires at 20 % and names the
tenant — the fix is agent-side, the classifier is only the messenger.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/VerdictPaths.cs` — vocabulary + `ClassifierRules`
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `ComputePriorVerdict`, `ApplyVerdictPath`, re-registration preserve
* `src/Backend/AutopilotMonitor.Functions/Helpers/VerdictPathDerivation.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.VerdictCalibration.cs` — sweep + `BuildVerdictCalibrationAggregates`
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs`
* `src/Backend/AutopilotMonitor.Functions/Helpers/VerdictCalibrationRadar.cs`, `Services/MaintenanceService.VerdictCalibrationRadar.cs`
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `get_verdict_calibration`
* `src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts` — LLM-facing response shaping (`minSharePct` / `top`, explicit nulls)
* `src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx`
* Tests: `VerdictPathTests`, `VerdictCalibrationTests`, `VerdictCalibrationRadarTests`
* Related: [Silent-Session Classification](silent-session-classification.md), [Device Journeys](device-journeys.md), [Rule Regression Radar](rule-regression-radar.md)
@@ -1,160 +0,0 @@
---
type: Concept
title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS)
description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority.
resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs
tags:
- backend
- vulnerability
- nvd
- epss
- kev
- msrc
- cache
timestamp: 2026-08-29T18:00:00+02:00
---
# Vulnerability Correlation Sources
The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software
list with the session; the backend turns it into CVE findings entirely from cached public
reference data. Four sources, all trustworthy primaries, no aggregators:
| Source | What it contributes | Fetch shape | Cache |
| --- | --- | --- | --- |
| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d |
| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` |
| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` |
| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync |
Nothing about a tenant leaves the backend. The one outbound query that carries content is the
EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic
CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the
terms page attribution block.
# Schema
## Cached CVE (`CachedCve`, serialised into `CveDataJson`)
```
CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate,
EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl}
```
`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS
properties existed deserialise with those as `null`, and a row is never rewritten just to add
nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs
per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request.
`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly
refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the
single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the
existing value through so it does not look like a fresh NVD fetch to the 7-day TTL.
## Per-finding output (report `findings[].vulnerabilities[]`)
```
cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"),
isKev, kevDueDate, kevRequiredAction, knownRansomware,
epssScore?, epssPercentile?, epssDate?,
priority ("act" | "attend" | "track"),
msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches)
```
Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`.
`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2)
recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore`
(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary
(`TopCve.epssScore` / `priority`) reads.
## Priority (`CvePriority.Compute`)
* `act` - in CISA KEV.
* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0.
* `track` - everything else.
Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad,
priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach
`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a
product decision, not a tuning knob.
# Throttle contract (NVD)
The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried
three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore
means NVD is refusing us for real. Two things then happen, and one deliberately does not:
1. `NvdFetchResult<T>` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object
so enumerating callers keep working, but **nothing that persists may treat it as an answer**:
the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not
touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response
was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from
every report for a week.
2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s),
otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no
retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never
a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and
reports the remainder as `cveSkippedThrottled`.
3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate
"throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable
(better than nothing, logged); a product with no row at all is reported without CVE data for
that run and gets a real attempt on the next correlation.
# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC) and the manual buttons
1. KEV catalog refresh (blob mirror updated).
2. MSRC index refresh (last six Patch Tuesdays).
3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle).
4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score /
percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the
response keeps yesterday's score: a failed batch never erases data.
Steps 3 and 4 live in `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk),
which the timer and the Global-Admin buttons share, so a manual run writes exactly what the nightly
run writes. Four endpoints, one per source, each with its own "Last … Sync" line on the admin page
(`GET vulnerability/sync-status`):
| Button | Endpoint | Shape | Timestamp |
| --- | --- | --- | --- |
| Sync KEV | `POST vulnerability/sync` | synchronous | `VulnerabilityDataLastSyncUtc` |
| Sync MSRC | `POST vulnerability/sync-msrc` | synchronous | `MsrcLastSyncUtc` |
| Refresh NVD Cache | `POST vulnerability/sync-nvd` | **202 + background** (minutes, NVD-rate-limited); 409 while running; the page polls `nvdRefreshRunning` / `nvdLastRun` every 10 s | `NvdCacheLastRefreshUtc` - stamped only for a walk that reached every stale row (no throttle cut-off) |
| Sync EPSS | `POST vulnerability/sync-epss` | synchronous (seconds); 409 while running | `EpssLastSyncUtc` |
The timer skips a walk whose slot a manual run currently holds (`cveSkippedBusy` in telemetry)
rather than queueing a duplicate. `nvdLastRun` / `epssLastRun` are in-memory on the answering
instance (null after a cold start); the persisted timestamps are the durable signal.
Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored /
epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters; the manual EPSS run emits
`ManualEpssSyncCompleted`.
# Examples
* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority =
act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit.
* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 ->
`track`. Same severity band, different order of work.
* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the
next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows
rewritten` - EPSS is independent of NVD's mood.
# Not done (recorded)
* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV
mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and
each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a
public exploit?".
* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an
Intune admin, not adopted.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown`
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse`
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync`
* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync`
* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs`
* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
-106
View File
@@ -1,106 +0,0 @@
---
type: Concept
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
tags:
- contracts
- guard-tests
- signalr
- web
timestamp: 2026-08-13T00:00:00+02:00
---
# Drift Guards
Follow-up to the 2026-08 fragility audit (see [Lifecycle Manifests & Session Scope
Resolution](backend/lifecycle-manifests-and-session-scope.md) for the earlier rounds).
Each section below replaces a hand-synchronized mirror with an enforced contract: a
change on one side now fails a build, a type check, or a test on the other side.
# Schema
## SignalR message names — one catalog, typed on both ends
`Constants.SignalRMessages` (Shared) is the complete catalog of SignalR target names.
Backend senders (output bindings and the imperative `SignalRNotificationService`)
reference the constants; the hub name is `SignalRGroupHelper.HubName`. The catalog is
exported into `shared-manifests.json` (`signalRMessages` section) by
`SharedManifestParityTests`, and the web's `SignalRContext.on/off` type their event
name against the generated union (`lib/signalrMessages.ts`) — subscribing to a name
the backend never sends is a tsc error, not a silent no-op. `newevents` is the one
legacy lowercase name; it is a persisted wire name, do not "fix" the casing.
## Inverted-tick RowKeys — one codec
`RowKeyCodec` (Functions/Helpers) is the single encoder/decoder for
`rev(t) = MaxTicks - t.Ticks` RowKeys. Every writer routes through it; the persisted
per-table shapes (D19 standard, AuditLogs `!`-prefix, UserActivity's legacy D20 width,
ConfigurationBackups' 28-char truncation) are pinned by `RowKeyCodecTests` and must
never change — a width/prefix change reorders live tables against their existing rows.
`BusinessTimestamp` keeps the OData clause builders and delegates decode to the codec.
## Tombstone rows carry their table
`DeletionRowDump.Table` names the source table on FINAL (Tombstone) rows — the one
step that mixes two tables while `DeletionStep.Table` is null.
`DeletionTombstoneTables.Resolve` is the only consumer-side resolution (delete +
restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic survives
solely as the fallback for manifests written before the field existed and must outlive
one full manifest-retention cycle.
## Anonymous OkAsync responses — frozen, shrink-only
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
sites as a per-file baseline. A new anonymous response (new file or count above
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
list stays truthful and only ever shrinks.
## Tenant settings — per-section PATCH
The web's Settings sections no longer PUT the full ~92-field configuration. Each
section's exact write surface lives in `app/settings/sectionFieldMap.ts` (owned fields
plus documented `alsoWrites` write-throughs); `saveConfiguration` diffs those fields
against the loaded config and PATCHes only the changes through
`PATCH config/{tenantId}/fields` — the transactional endpoint (CAS + fail-closed
backup + exactly-these-fields verify + auto-rollback) that previously served MCP only.
The policy line is TenantAdminOrGA; a tenant admin runs on the stricter
`TenantConfigCallerTier.TenantAdmin` whose deny-list turns GA-only fields into
explicit 400s. Deploy order: backend before web. `sectionFieldMap.test.ts` pins
field↔section ownership (each field exactly one owner, all fields on the model, none
server-denied); backups/revert stay GlobalAdminOnly.
## Scoped endpoint routing — one decision object
`lib/scopedApi.ts` owns the tenant/global endpoint-pair choice: pages pass their scope
hook object (`routeGlobal`, `selectedTenantId`, `effectiveTenantId`) instead of
hand-rolling `routeGlobal ? global(..., sel || undefined) : tenant(tid, ...)` with the
tenant parameter at a different position per pair. `useGlobalAdminScope` is now a thin
projection over `useAggregatedAdminScope` (one hook, aggregated as a mode) via the
pure `resolveConcreteScopeView`. `lib/scopedFetch.ts` adds the shared JSON+ok-check
fetch layer. `lib/navVisibility.ts` extracts the sidebar's roles × nav-config × guard
logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it"
mismatches (plain Operator and GlobalReader vs. `/settings` — pending a user decision).
# Examples
Renaming a SignalR message end to end: change the constant in
`Constants.SignalRMessages` → `AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter
SharedManifestParityTests` → `node scripts/generate-shared-manifest-types.js` → tsc
now flags every stale web subscription literal.
Adding a field to a Settings section: add it to the section in `sectionFieldMap.ts`
(tsc validates the name against the manifest), wire the form state into the
`updatedConfig` construction in `TenantConfigContext`, and extend the expected-fields
list in `sectionFieldMap.test.ts`.
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

-92
View File
@@ -1,92 +0,0 @@
---
okf_version: "0.1"
---
# Autopilot Monitor — Technical Knowledge Bundle
Contributor-facing technical documentation for Autopilot Monitor, organized as an
[Open Knowledge Format (OKF)](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
knowledge bundle. Customer-facing product documentation lives at
https://docs.autopilotmonitor.com (separate repository).
# Architecture
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
* [Zero User-Targeted Apps — the starved user-phase completion gate](agent/zero-user-apps-completion-gate.md) - Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate — IME skips its "Completed user session" line entirely in the zero-app branch — turning the 30-minute completion backstop into `esp_exit_without_completion_evidence` on a third of one tenant's enrollments, with the user at the desktop and RealmJoin still installing. The two fixes: read the line IME does write (IME-USER-SESSION-ZERO-APPS), and never fail a session while a completion gate with its own armed deadline is deliberately holding it.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable.
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
* [WiFi SSID and the Windows 24H2 Location Gate](agent/wifi-ssid-location-gate.md) - Why wifi_signal_info vanished on Windows 11 24H2+: WlanQueryInterface(wlan_intf_opcode_current_connection) is gated behind precise-location consent, the prompt only fires in a user context outside System32, and SYSTEM in session 0 falls back to the device-level ConsentStore value - so netsh (same gate) could never cover for it. The tiered WifiInfoProvider (native, then ungated WinRT GetConnectedSsid, then netsh), SSID-only semantics on tier 2, and the Warning-level rc=5 diagnostic that names the gate.
# Rules
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
* [Rule ID Namespace & Collision Guards](rules/rule-id-namespace.md) - Why rule identity is the human-readable ruleId (no GUIDs) with tenant isolation via Table Storage partitioning, the reserved built-in namespace (RuleIdPolicy), which collisions are possible (custom-vs-custom across tenants: allowed and harmless), and the guards keeping the global RuleStats aggregate catalog-only and the global partition inside the reserved namespace (combine.js, reseed, cross-tenant RuleState GC).
* [Analyze Rule Evaluation Triggers](rules/analyze-rule-triggers.md) - Opt-in `evaluateOn` interim triggers (`whiteglove_sealed`, `on_event:<type>`) so rules can fire before a session is terminal, with RuleResults update-semantics (interim → finalize/resolve, NotifiedAt notification dedupe) replacing the permanent dedupe freeze, KO/stat suppression on interim runs, the ingest-side cached trigger registry, and the authoring lints that keep interim rules monotonic.
# Backend
* [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow.
* [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events.
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
# MCP Server
* [MCP Protocol Eras & Client ID Metadata Documents](mcp/protocol-eras-and-cimd.md) - One per-request factory serving the 2026-07-28 stateless revision (server/discover, ttlMs/cacheScope, Mcp-Method routing) and 2025-era initialize clients side by side, why the legacy leg stays a hand-wired JSON+gzip transport, the never-public cache-hint contract, and the CIMD client-registration path (SSRF-gated document fetch, same redirect allowlist) next to the deprecated dynamic registration.
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
# Web Portal
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
# 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.
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections.
# 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.
-305
View File
@@ -1,305 +0,0 @@
# Log
## 2026-08-29 (5)
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
## 2026-08-29 (4)
* **Creation**: Added `backend/ime-msi-archive.md` — the version-verified, multi-host IME installer archive: MsiProductVersionReader (managed CFB/MSI Property reader), candidate host walk, Failed:VersionMismatch, sighting re-queue with 24 h backoff, and the 2026-08-29 1.105.103.0 poisoning that motivated it.
## 2026-08-29 (3)
* **Update**: `backend/vulnerability-correlation-sources.md` - the admin page's Data Sync Status is complete: next to Sync KEV / Sync MSRC there are now Refresh NVD Cache and Sync EPSS, each with its own last-sync line (`NvdCacheLastRefreshUtc`, `EpssLastSyncUtc` on AdminConfiguration; shared manifest regenerated). The NVD and EPSS walks moved out of the timer into `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk) so the button and the nightly run are literally the same code; the NVD walk is minutes long and rate-limited, so `POST vulnerability/sync-nvd` answers 202 and the page polls `sync-status` (`nvdRefreshRunning`, `nvdLastRun`) while it runs, `sync-epss` runs synchronously. 409 while a walk is in flight; the timer skips a slot a manual run holds. Operator-only surface - no customer docs.
## 2026-08-29 (2)
* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change.
## 2026-08-29
* **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, 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 stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`.
## 2026-08-28
* **Update**: `CmTraceSkewTripwire` - the backend regression tripwire additionally requires >= 80 % of the individual IME deltas (relative to the other side's median) to sit within 2 min of some 15-minute grid multiple before `CmTraceTimeSkewRegression` fires; the ops event and its details now carry that fraction. Motivation: the first soak week produced 7 fires, all backlog replays - a relaunched agent re-tailing the IME log uploads one burst holding most of the session's IME samples with a continuum of ages whose median hit the grid by chance (session d832560a: 508 of 627 samples, 46...733 min, median 91). Era-mixing regressions (every line on the grid) still fire. Doc: `docs/agent/cmtrace-time-resolution.md`.
## 2026-08-27 (2)
* **Update**: `SecurityValidator` / `DeviceAssociationValidator` - Microsoft released Windows Autopilot device association on 2026-08-25 (learn.microsoft.com/autopilot/device-preparation/device-association/overview: TPM-backed UEFI marker, device-targeted device preparation policies, associated devices are marked corporate-owned by Intune with no corporate identifier upload). The DevPrep association lookup therefore leaves shadow mode and becomes the third accepting method of the device-validation gate, evaluated inline after the Autopilot and Corporate Identifier lookups and before the Cloud PC fallback; a match sets `ValidatedBy = DeviceAssociation`, a miss joins the combined 403 detail, a transient failure the 503 Retry-After - the same contract as its siblings, so enabling it can only widen acceptance, never reject a device the other methods would have taken. `ValidateDeviceAssociation` counts toward "at least one validation enabled" and is no longer GA-only anywhere (`UpdateTenantConfigurationFunction` revert, `TenantConfigPatchService.GaOnlyFields`, the Global-Admin gate on the settings toggle); the toggle sits under Device Preparation next to Corporate Identifier and follows the shared consent rule (first serial-based validation carries the consent, any further one is a plain persisted toggle). Known gap carried over from the shadow phase: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint is still undocumented in Graph reference and every production lookup so far answered `matched=False` for Autopilot v1 devices, i.e. the positive match path is unproven in the field - first associated enrollment must be checked for `ValidatedBy = DeviceAssociation` on the session row. Landing hero carries a release pill linking to the customer docs; dashboard tip added. Backend before web; customer docs updated in `autopilotmonitor-docs`.
## 2026-08-27
* **Update**: `backend/silent-session-classification.md`, `backend/verdict-calibration.md` - the first real calibration read tuned what it measures. Rule 5 grew a past-grace fork 5a: 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, and the sampled desktop half had clean app records; no desktop or any failed app stays Incomplete. The max-lifetime watchdog (either event shape) now skips the AwaitingUser grace entirely - all five observed maxlife parks expired unhealed, and late telemetry heals a terminal verdict anyway - while WhiteGlove rule 1b keeps its park (its one episode healed). The drift radar got floors (≥ 10 window hits; per-path share regression needs ≥ 5 baseline hits, so new/renamed vocabulary cannot "regress") and legacy continuity: rule-shaped `legacy:*` paths count into the silence and classifier-verdict group sums on both sides of the 2026-08-23 cutover, which dissolves the lift-124 silence artifact and lets sub-floor episodes clear themselves. Backend only; the new path shows up in the matrix on its own.
* **Update**: `backend/tenant-activation-welcome-mail.md` - the reverse lookup. A delivery log names only the recipient, so an address had to lead back to its tenant: the tenant console's search now matches the notification and contact address as well as domain and tenant id, with the quoted exact mode unchanged. The addresses sit in `PreviewWhitelist`, not on the tenant config, so a new GA/Reader-only `GET /api/preview/notification-emails` returns them as one map (single cross-partition scan of `RowKey eq 'notification-email'`) instead of a point read per tenant; both sides join on the lowercased tenant id because the address rows carry the JWT `tid` while 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 - and the addresses stay out of the list rows (search key, not a column). Match rules moved into `tenantSearch.ts` with unit pins. Backend before web.
## 2026-08-26
* **Update**: `agent/wifi-ssid-location-gate.md` - the network timeline now explains its blank signal too, with no agent change: `buildSegments` already merges `wifi_signal_info` into the covering WiFi segment, so `wifiDataLimitedReason` rides in with the sample. Segments that never got a sample (a mid-session reconnect emits none) inherit it - the gate is a device-level setting and cannot flip mid-enrollment - while a segment with a real reading is never overwritten. Tooltip shows `Signal: unavailable - Location services off on this device`; the "Avg WiFi signal" stat reads `n/a (Location services off)` instead of vanishing. `network_state_change` is deliberately left alone: it has never carried `signalPercent` or `radioType` (only `before_/after_wifiSsid` reaches its payload), so there is no absent field there for a reason to explain, and the SSID it does carry is unaffected by the gate. Web-only change.
## 2026-08-26
* **Update**: `agent/wifi-ssid-location-gate.md` - the gate is now surfaced instead of only logged. When the WinRT tier wins and the native tier reported `rc=5`, `wifi_signal_info` carries `wifiDataLimitedReason = "location_services_off"` and the message gains `- signal unavailable, Location services off`, so the reason is readable straight from the timeline; the portal WiFi card (`DeviceDetailsCard`) matches that literal and names the Intune setting that restores the full payload. The claim is evidence-only - any other native failure leaves the field null rather than blaming a privacy setting, with `IsLocationGateDenied` pinned at its boundaries. New `wifi_signal_info` group in the MCP `DEVICE_PROPERTIES_CATALOG` so a fleet can be sized via `search_sessions` before any policy changes. Agent release + web deploy + MCP redeploy; backend untouched (the payload is opaque DataJson).
## 2026-08-26
* **New**: `agent/wifi-ssid-location-gate.md` - `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 from 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader. Cause is the 24H2 location gate on `WlanQueryInterface` with `wlan_intf_opcode_current_connection` - the only opcode the agent uses - which returns `ERROR_ACCESS_DENIED`; the consent prompt only fires for a process in the user context outside `System32`, so the agent (SYSTEM, session 0, pre-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 box reads the SSID fine via the per-user NonPackaged value. `netsh wlan` hangs off the same gate, so the 2026-07 netsh fallback (and its multi-language labels) never covered the case it was built for; more labels cannot fix it. New `WinRtWifiSsidReader` uses the documented replacement `WlanConnectionProfileDetails.GetConnectedSsid` (ungated, SSID only), bound via the CLR WinRT projection with no winmd/NuGet like `OobeStateReader`; 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. Agent-only change; needs an agent release.
## 2026-08-25
* **Update**: `backend/client-cert-tenant-binding.md` — stage 2 enforced: `Mismatch` now returns 403 via `LogRequestRejection(stage=certtenant)`. Switched on against 35,684 requests / 32 tenants at 100 % `Match`, with the Warning channel verified to be carrying other traffic so the zero was not an artefact. The rule is one function (`CertTenantBinding.Rejects`) and only `Mismatch` blocks; `ExtensionMissing` / `Unparseable` / `RequestTenantNotAGuid` are tolerated on purpose (the first is the candidate for later tightening and has a test pinning the tolerance). Evaluation fails open on its own exception, and the 403 body does not echo the certificate's tenant. Device binding (stage 3) stays shadow + Global-Admin-only and is unaffected.
## 2026-08-25
* **New**: `agent/zero-user-apps-completion-gate.md` — a Classic enrollment whose user has zero targeted Intune Win32 apps could not produce the evidence any user-phase completion path required: IME's zero-app branch (`ApplicationPoller`, verified against decompiled 1.104.102.0 and three older builds) `continue`s before its `Completed user session` statement, so `ImeUserSessionCompletedUtc` stayed null, all four arms of `ShouldTransitionToAwaitingHello` were dead, and the 30-min `advisory_completion` backstop resolved the session as `esp_exit_without_completion_evidence` — with a DAD-validated desktop, Hello provisioned and RealmJoin still installing. One tenant: 10 of 30 enrollments in a week, all false positives. Two fixes. (1) New pattern `IME-USER-SESSION-ZERO-APPS` reads the line IME *does* write and routes it through the same `OnUserSessionCompleted` callback, guarded to the AccountSetup phase and replayed if the observation predates the phase marker; `AreUserEspAppsSettled` now accepts an empty app list only with that explicit observation. (2) `HandleAdvisoryCompletionDeadlineFired` re-arms instead of failing while the RealmJoin gate is closed and its own deadline is still armed — session 75d6ae8e installed 25 RealmJoin packages inside the window, the last 2 min before the failure. `ime_user_session_completed` gains an `evidence` field (`zero_user_apps_enumerated` / `user_session_completed`); no new event type. The pattern reaches existing agents via the config API (an unknown action is a Debug line), the behaviour needs an agent release.
## 2026-08-24
* **Change**: `DeviceAssociationValidator` / `SecurityValidator` — the DevPrep association shadow now logs one line per real Graph lookup instead of one per request (`DeviceAssociationResult.ServedFromCache`, `AsCacheHit()` copies rather than marking the shared cached instance), the same gate the cert-device-binding shadow uses. Measured: one enrollment produced 263 identical lines off a handful of calls. Status of that preview, for the record: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint answers (6 calls, all HTTP 200, `transient=False`), but every result so far is `matched=False` from a single Autopilot **v1** device — which is the correct answer for a device that was never part of a Device Preparation policy. The matching itself is therefore still unproven, and the endpoint remains undocumented in both v1.0 and beta Graph reference, so it is not a basis for enforcement.
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3 verified live (`ea6a375`, gktatooine): 136 requests, all `Match`, device object ~72 s older than the session start, so no enrollment race in that run. Measured Graph cost: **1 call for 136 requests** (30 min positive cache), versus 25 for the per-request Autopilot validator — the positive TTL needs no change. The shadow log is now gated on `ServedFromCache` (one line per real lookup instead of per request); never-cached outcomes still log every time.
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3, the cert-to-device binding: new `IntuneDeviceBindingValidator` resolves the Subject-CN managedDevice id against the tenant's own `deviceManagement/managedDevices` (point-GET, no filter literal), modelled on `CloudPcDeviceValidator` and wired fire-and-forget like the DevPrep shadow so a cold Graph round-trip never enters the agent request path. New GA-only tenant toggle `ValidateIntuneDeviceBinding` (shadow — never touches `deviceValidated`), new optional Graph feature `IntuneDeviceBinding` → `DeviceManagementManagedDevices.Read.All` in `GraphFeatureCatalog` + the customer grant script, and `enrolledAgeSeconds` on every log line so the enrollment race can be measured before this could ever become a gate. Grep marker: `CERT-DEVICE-BINDING-SHADOW`.
* **Update**: `backend/client-cert-tenant-binding.md` — shadow telemetry moved off the dead `LogInformation` path: the `Match` outcome now rides on the request row as the `CertTenantBinding` dimension (`SecurityValidator` → `FunctionContext.Items` → `RequestTelemetryMiddleware`), which is unsampled and already exists per request, so the denominator costs nothing. Non-Match outcomes keep their Warning trace. Post-deploy verification of 249e39bc found zero rows across 32k requests — worker `LogInformation` never reaches App Insights (provider default rule Warning+), making "everything matched" indistinguishable from "never ran". `CertificateValidationResult.FromCache` removed with the sampling path it existed for.
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
## 2026-08-23
* **New**: `backend/telemetry-ingest-shaping.md` — `RequestTelemetryMiddleware` sets `SamplingPercentage=100` on its own item (worker request rows were adaptively sampled, ItemCount 2–5 on weekdays, despite host.json `excludedTypes`), the redundant host HTTP request copy is dropped by a workspace transformation DCR (`infra/appinsights-workspace-transforms.bicep`, timer/queue rows kept), and `StorageDependencyFilterProcessor` now also drops expected storage outcomes (404/412/409, both InProc and HTTP shapes) and successful SignalR REST calls; the host-emitted SignalR binding calls (bulk of the volume, SDK `rdddsc`) are dropped by a second `AppDependencies` flow in the same DCR. Measured ≈ −1.3 GB/week.
* **Update**: `backend/table-schema-sentinel.md` — "Measuring it": `StartupTelemetryService` emits `BackendStartupMs` (process start → ApplicationStarted) and `BackendTableInitMs` as App Insights custom metrics with version/commit/fullPass dimensions; the MCP server writes one `{"type":"boot","readyMs"}` stderr line per start (query in `.claude/commands/mcp-telemetry.md` §A2).
* **New**: `backend/table-schema-sentinel.md` — startup table initialization gated by a schema sentinel (SHA-256 over `TableNames.All` in `AdminConfiguration`/`SchemaSentinel`/`tables`): matching sentinel ⇒ zero `CreateTableIfNotExists` calls on a cold start, missing/stale ⇒ one full pass + sentinel rewrite. SessionsIndex startup backfill only after a full pass, `IsSessionIndexEmptyAsync` no longer treats errors as "empty", conditional-insert claim so one scaled-out instance backfills. Daily maintenance runs `IStorageInitializer.EnsureAllAsync` to repair out-of-band table deletions.
* **Update**: `backend/verdict-calibration.md` — MCP `get_verdict_calibration` response shaping: per-row trend denominators dropped, explicit nulls, `minSharePct` / `top` with an `omitted` report.
* **Update**: `backend/verdict-calibration.md` — share-regression radar restricted to backend-decided paths; agent-declared/registration paths (customer workflow mix) and derived legacy paths no longer alert, stale episodes re-arm themselves.
* **Update**: `backend/time-attribution.md`, `backend/device-journeys.md`, `backend/verdict-calibration.md` — the rolling maintenance sweeps (time attribution, device journeys, verdict calibration, calibration-radar discovery) now share ONE projected 35-day cross-tenant Sessions scan per tick (`LoadSweepWindowSessionsAsync` + `MaintenanceSweepSessionProjection`, pinned by `MaintenanceSweepProjectionEquivalenceTests`) instead of four full-table drains; each sweep slices its own window by StartedAt.
* **New**: `backend/verdict-calibration.md` — the classifier thermometer: every status write now stamps `VerdictPath` (required parameter on `UpdateSessionStatusAsync`; `EnrollmentTimeoutClassifier` returns its rule id), overriding a verdict preserves `PriorStatus`/`PriorVerdictPath`, `VerdictPathDerivation` attributes pre-instrumentation rows read-side (`legacy:*` when ambiguous). New daily aggregate `VerdictCalibrationAggregates` (per-path count/derived/eligible7d/reEnrolled7d/overridden-by-admin|late|other, sweep after the device-journey pass, 180d retention), `GET global/metrics/verdict-calibration`, MCP `get_verdict_calibration`, admin metrics section, and the `VerdictCalibrationRadar` (share / silence-share / r6 evidence-gap episodes in the tracker keyspace `verdictcalibration|`, `VerdictCalibrationDrift` ops event, no tenant bell).
* **New**: `backend/silent-session-classification.md` — one place for the silent-session verdict: `EnrollmentTimeoutClassifier` rule order incl. the new rule 1c `IsSelfDeployingProvisioned` (self-deploying profile + Device Setup all-succeeded + no explicit failure ⇒ Succeeded reconcile, no grace/AwaitingUser — kiosk tenant aebdce78 audit: 836 finished devices parked AwaitingUser/Incomplete), the sweep stage-1 gates (`TryMarkWhiteGloveAwaitingUserAsync`, new `TryReconcileSelfDeployingAsync`), the ingest callers (max-lifetime, late-telemetry heal, stall probe) and the new admin retro mode `self_deploying_silent` (`GetSelfDeployingSilentSessionsAsync`). Agent companion: `SessionRegistrationHelper` waits up to 15s for a network link and retries 6× (~62s) instead of 5× (~30s) so the reboot relaunch on Wi-Fi no longer exits 7 before the link is back.
* **New**: `agent/diagnostics-package.md` — the diagnostics ZIP's built-in collection is now ONE catalog in Shared (`DiagnosticsBuiltInSections.All`): the agent iterates it in order and the backend serves the same list to the portal (`GET diagnostics/paths`, MemberRead), so what administrators see is exactly what the agent collects. New conditional sections: RealmJoin client/package/Chocolatey/per-user logs behind the tenant's RealmJoin Watcher toggle (mirrored onto `AgentConfiguration.EnableRealmJoinWatcher` by the merger) and the Device Preparation bootstrapper event log behind the deterministic WDP marker (probe once per build, fail toward Classic); every skip is a manifest line. Built-ins bypass `DiagnosticsPathGuards` by design (reviewed code, `%ProgramData%\RealmJoin` is not allowlisted); caps/reparse/evtx export still apply. `MergeDiagnosticsLogPaths` dedups global ∪ tenant paths. Portal: collapsed read-only catalog + one-line rows in both cards, tenant context dropped the GA-only global fetch.
* **Update**: `rules/gather-rule-guardrails.md` — `DiagnosticsPathGuards` scope clarified: configured diagnostics paths only, never the built-in catalog.
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Template overrides" section: Global Admins can replace the welcome/farewell HTML in the portal (Settings → Email Templates); `EmailTemplateService` resolves override-or-built-in per send (PreviewConfig table, partition `EmailTemplates`, 5-min cache, `{{domainName}}` placeholder, 30k limit, fail-soft send path), GA endpoints `global/email-templates/{kind}` (+`/test` to the caller's tenant contact address). Also: the mail header is image-free (CSS-drawn brand mark, gradient stripes and clip-path edge as progressive enhancement).
## 2026-08-22
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it.
* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace.
## 2026-08-21
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
* **New**: `agent/system-timeline-watcher.md` — clock-step and sleep-episode ground truth from the System event log: one `EventLogWatcher` (Kernel-General 1, Power-Troubleshooter 1, Kernel-Power 507; 506 deliberately unconsumed) emits `system_clock_changed` (oldTime/newTime/signed timeDeltaMs/reason/process; 2 s micro-slew floor, ≥5 min = Warning+immediate) and `system_sleep_episode` (kind sleep/hibernate/modern_standby, authoritative `enteredAt`/`exitedAt` payload instants, 60 s Modern-Standby floor). WindowsUpdateTracker-style backfill (24 h default, `system-timeline-watermark.json`) recovers pre-agent records; ConfigVersion 39 (`Collectors.EnableSystemTimelineWatcher`). Consumers: TimeJumpBadge cause `clock-set` (ground truth beats inference), SessionInfoCard standby note next to the unchanged wall-clock duration, `SleepSpan`s in time attribution (AttributionVersion 3, RebootSpan clone), and ANALYZE-DEV-012 (info, single, ≥5 min episode; deliberately the only new rule — clock issues stay with DEV-008).
* **New**: `agent/power-state-watcher.md` — live AC/battery observation during enrollment: `PowerStateWatcherHost` subscribes to the WMI push `Win32_PowerManagementEvent EventCode 10` (no polling; re-probes `GetSystemPowerStatus` per push, 5 s trailing-edge debounce, never armed on battery-less devices, `Collectors.EnablePowerStateWatcher` kill-switch, ConfigVersion 38). The pure `PowerStateTransitionTracker` emits `power_state_change` with `transition` = ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (each level latched once per lifetime, lowest-crossed-only on jumps, baseline-below-level covered, emission cap 20). Classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent). New analyze rules ANALYZE-DEV-009 (thresholdPercent=15, high) and ANALYZE-DEV-010 (transition=ac_to_battery, warning), both with `on_event:power_state_change` interim triggers and deliberately single-required-condition (event_data conditions have no same-instance join).
* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields.
* **New**: `rules/rule-id-namespace.md` — rule identity model: human-readable `ruleId` strings (no GUIDs), tenant isolation via Table Storage partitioning (`global` = built-in/community, tenantId = custom), the reserved built-in namespace from `RuleIdPolicy`, and the four collision guards: catalog-only global `RuleStats` aggregate (same-ID custom rules of unrelated tenants no longer sum into one `global_{ruleId}` row), combine.js duplicate/namespace fail, GitHub-reseed rejection of non-reserved IDs into the global partition, and the namespace gate on the cross-tenant RuleState GC.
* **New**: `backend/app-install-duration-attempt.md` — attempt-scoped app install durations: `DurationSeconds` = status-defining (last) attempt anchored on the new max-folded `LastAttemptStartedAt` (StartedAt stays the earliest-wins window/version-ordering key; legacy rows without the anchor keep the span fallback); `InstallPassCount` with replay-guarded additive fold; weaker-terminal guard (completed-with-Skipped never overrides Installed/Error, in-batch and cross-batch); attempt-scoped `DownloadDurationSeconds` pairing; the version-regression radar's both-sides cutover gate; time attribution counts the union of per-pass active segments (occupancy merges segments, hulls stay for display/what-if); agent payload timing re-arms both endpoints on terminal→active. No backfill — pre-cutover rows keep span semantics.
* **Update**: `agent/decision-engine.md` — activity-based RealmJoin timeout extension (report 55e6afd61c9d: a large RJ package catalog outlived the 60-min window mid-install — the timer arms at RJ-agent-MSI detection, so the first deployment had effectively ~44 min). When the `RealmJoinTimeout` deadline fires with `LastDeploymentPhase` 100/101 and deployment activity (phase change or package observation, persisted in `RealmJoinFacts.LastActivityUtc`; the detection-time phase capture deliberately excluded) inside the last 60 min, the deadline re-arms to `lastActivity + 60 min` (Info event `realmjoin_timeout_extended`), clamped at detection + 4 h. `realmjoin_timeout` now carries `reason` = `hard_timeout` / `inactivity` / `absolute_cap`; a fire of a superseded deadline incarnation dead-ends. Idle detection and phase-200/210 sessions keep the original 60-min behavior.
* **New**: `backend/send-time-frame-separation.md` — P14: the agent stamps one device-clock send timestamp per upload attempt (`X-Send-Time-Utc`), the ingest function server-stamps it as `SentAt` on every event of the request. `SentAt − OccurredUtc` is pure spool delay, `ReceivedAt − SentAt` is network + device-vs-server clock offset — the two were indistinguishable before and had to be separated statistically. The `clock_skew` condition measures SentAt-carrying batches directly (spool-immune, no spread cap, IME-only sessions become measurable; evidence carries `sentAtBatchCount`), legacy batches keep the median path; the CMTrace tripwire deliberately stays on its differential math (common-mode clock offset cancels there).
## 2026-08-20
* **Update**: `agent/cmtrace-time-resolution.md` — new "Backend regression tripwire" section: the P6 split. Operator side: `CmTraceSkewTripwire` piggybacks on the terminal counter reconcile's partition scan (`Δ = ReceivedAt − OccurredUtc` per source, 15-min grid + 2-min residual, ≥20 samples / ≥3 distinct batches per side, bias-dominated sessions suppressed, `measuredWriterOffsetMinutes` never consulted) and emits the `CmTraceTimeSkewRegression` ops event — goal state: never fires; kill switch `CmTraceSkewTripwireDisabled`. Customer side: new `clock_skew` analyze condition source (`skewMetric: clock_jump | sustained_offset`, threshold in seconds, batch-median frames with spool-spread filter, plateau step detection, end-state persistence, IME-derived events excluded) behind the new built-in `ANALYZE-DEV-008` (300 s = the Kerberos/Entra 5-minute skew boundary) — the during-enrollment complement of ANALYZE-DEV-007's one-shot NTP check. P5 (agent-side observability event) is subsumed by the tripwire.
* **New**: `agent/cmtrace-time-resolution.md` — the era-aware answer to the CMTrace timezone skew: a bias-less line read in a provably fresh pass anchors its own UTC offset against the agent clock (15-minute grid, 2-minute residual), so interleaved writer eras in one file resolve line by line; everything not provably fresh keeps the uniform reader-zone fallback and says so via `sourceOffsetOrigin`. Freshness is a per-file `LastCheckedUtc` stamped on every poll look (empty first sights included) with a 30-second window, never restored across restarts. Replaces the reverted per-file offset application (04b1a7c6); the per-file measurement stays observational, now with the anchor timestamp logged as a tripwire for the still-unexplained e9753578 label/anchor crossing.
* **Update**: `agent/post-reboot-completion-recovery.md` — retitled to "ESP Completion Starvation — the sits-d Cloud-PC complex" and reframed on the evidence: the reboot was a red herring. The five stuck Cloud PCs carried `SkipUserStatusPage=True` (read 10+ times across three agent processes, `True` every time) and Windows honoured it — no user ESP page, hence no Shell-Core 62407 and no AccountSetup provisioning registry, verified over a six-hour uninterrupted post-reboot run with the watcher armed. Arm B of `ShouldTransitionToAwaitingHello` was open from the bootstrap onward, but every evaluation site hangs off a signal a skip-user flow cannot produce, and the `AdvisoryCompletion` backstop arms exclusively from an `EspExiting` signal — an open gate nobody knocks on. Fix: `HandleImeUserSessionCompletedV1` accepts the observed skip in place of the final-exit evidence (`skipUserExitEquivalent`; anchor + genuine IME completion + real-user desktop stay mandatory, WDP excluded). The knock deliberately lives on the IME edge, not desktop arrival — ordering-robust against Fix 10's AccountSetup bounce-back and semantically "user-phase work finished" rather than mid-install. `ClassicSkipUserEspUserSessionCompletionTests` replays the real 8110e262 ordering end to end. Docs and comments no longer claim an ESP-exit recovery that does not exist (stray duplicate summary in `ShellCoreTracker`, `ImeLogHost` restore-callback comment); the residual `SkipUser=false` exit-in-downtime gap is named honestly and surfaced by `ANALYZE-ESP-005` via `session_stalled` instead of being patched with an unorderable replay.
## 2026-08-19
* **New**: `agent/post-reboot-completion-recovery.md` — a forced mid-ESP reboot (DeviceGuard/DmaGuard) used to cost the session its AccountSetup completion permanently. Two defects: `ShellCoreTracker.BackfillRecentEspExitEvents()` existed but had no caller, so every agent restart dropped an ESP exit (62407) written while it was down; and the user-apps-settled synthesis was edge-triggered on `EspExited` only, so apps settling afterwards never re-opened the gate. `EspAndHelloHost.Start()` now backfills with a lookback sized to the downtime (`previousExit=reboot_kill` → since `LastBootUtc`, clamped to 360 min), and `ReevaluateUserAppsSettledSynthesis()` is chained into `ImeLogTracker.OnAppStateChanged`. No new completion path — the gate conditions are unchanged. Evidence: sits-d Cloud PCs, five `Incomplete` sessions on healthy devices, two of them at `138/138 apps, 0 failed`. Hardened over two review rounds: the remembered edge must be a **live** exit that is **confirmed** post-AccountSetup (a replay carries no evidence of its own position — Shell-Core writes the same `CommercialOOBE_ESPProgress_Page_Exiting` for the intermediate and the final exit, and the registry check reads *now*, not the event's time); the replay hands over the **newest** exit rather than the oldest; the replay is narrowed to the Hello-wizard start (62404) alone — a conservative fact that can only make the agent wait longer. A replayed ESP exit is never re-raised at all: Windows writes the identical description for the intermediate and the final exit, the reducer orders exits by ingest ordinal (fresher than reality for a replay) and `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit, so arm C of `ShouldTransitionToAwaitingHello` would open on a historic handoff once the restored state carries AccountSetupEntered + IME user session + desktop. A replayed ESP failure is likewise dropped (it could fail a session that recovered). Skipped records are reported as an `agent_trace`. The window is **measured** from the previous run's last known liveness (snapshot mtime, widened by `LastBootUtc`) instead of a constant — the scheduled task has a BootTrigger only and no restart-on-failure, so a crashed agent returns hours later and five minutes would lose the exit every time. On `first_run` the replay stays off, leaving the happy path untouched.
* **Update**: `rules/analyze-rule-triggers.md` — `on_event:session_stalled` documented as an established interim trigger for the never-terminal stuck session (one emission per session, no throttle needed). `ANALYZE-ESP-005` carries it: both its conditions are monotonic and it has no terminal precondition, and it fires at confidence 95 on the sits-d stalled stream. `ANALYZE-APP-015` deliberately does NOT — it is the worked example of the terminal-precondition trap (lint 1) — and `ANALYZE-ESP-001` does not either, because its `phase_duration` gate on a still-open phase is unverified mid-run. New `ANALYZE-APP-016` ("Required Apps Never Started — ESP Apps Gate Starved") turns the existing `app_install_starved` events into a customer-facing finding and, unlike `ANALYZE-APP-015`, fires even when the session succeeded — session `3b9291aa` was recorded `Succeeded` with 99 required apps that never started installing.
* **New**: `script-publishing.md` — the customer script publishing chain. `.github/scripts/Publish-BootstrapScripts.ps1` becomes the single owner (dev render, version-bump guard, uploads, version oracles, alias verification), called both by the new `publish-scripts.yml` (push to `main` on `scripts/Bootstrap/**` and `scripts/CustomerSetup/**`) and by the `publish_as_stable` branch of `build-agent.yml`. Script publishing no longer waits for an agent release. Blobs are published in a deterministic CRLF/UTF-8-no-BOM form so byte comparisons do not depend on the checkout; the Pester + ASCII gates move into the reusable `bootstrap-script-gates.yml` and block the upload; `AdminConfiguration.LatestBootstrapV2ScriptVersion` now has exactly one writer. The version-bump guard compares a code fingerprint (PowerShell token stream without comments, newlines and line continuations), so only behaviour changes require a `$ScriptVersion` bump — comment and formatting changes publish with a warning.
## 2026-08-18
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
## 2026-08-17
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
## 2026-08-14 (later)
* **Update**: `rules/analyze-rule-triggers.md` — status SPEC → IMPLEMENTED. Shipped: `evaluateOn` on the analyze-rule schema (grammar-validated server-side and in validate_rule), `AnalyzeRunContext`-driven engine runs (Terminal/Interim/Reanalyze) with the RuleResult lifecycle columns (FirstDetectedAt/LastEvaluatedAt/IsInterim/ResolvedAt/NotifiedAt), interim rule filtering + KO/stat suppression, resolve/finalize update-semantics, the NotifiedAt one-send-per-(session,rule) notification dedupe (preserved across the manual reanalyze rebuild), the ingest-side `InterimTriggerRegistry` (5-min TTL, fail-soft) gating `interim_trigger` / `whiteglove_sealed` enqueues, the three MCP authoring lints, web preliminary/resolved rendering, and ANALYZE-ID-004 as the first interim-enabled rule.
## 2026-08-14
* **Creation**: Added `rules/analyze-rule-triggers.md` — SPEC for opt-in `evaluateOn` interim evaluation of analyze rules: `enrollment_end` default preserves today's behavior, `whiteglove_sealed` + `on_event:<type>` close the WhiteGlove notification gap and the never-terminal stuck-session gap; RuleResults update-semantics (FirstDetectedAt/IsInterim/ResolvedAt/NotifiedAt) replace the permanent dedupe freeze; interim runs suppress KO + stats, notifications fire on first detection; three new validate_rule lints (terminal-precondition trap, unknown trigger type, KO-on-interim).
## 2026-08-13 (late evening)
* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet.
* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published.
## 2026-08-13 (evening)
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
## 2026-08-13
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
## 2026-08-12
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
## 2026-08-11 (later)
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
## 2026-08-11
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
## 2026-08-10
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
## 2026-08-06
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
## 2026-08-05 (later)
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
## 2026-08-05
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
## 2026-08-04
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
## 2026-08-02
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
## 2026-07-29
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
## 2026-07-27
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
## 2026-07-24
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
* **Creation**: Added `agent/secureboot-ca2023-detection.md` — ANALYZE-SEC-001 warned on devices whose firmware already carried the 2023 certificate, because the agent only read the `SecureBoot\Servicing\UEFICA2023Status` registry value, which mirrors the Windows Update rollout rather than the firmware (customer report). The agent now reads the UEFI `db`/`KEK` variables natively (`GetFirmwareEnvironmentVariableExW` with `SeSystemEnvironmentPrivilege` enabled on the process token — first privilege-adjustment code in the repo) and emits per-certificate booleans on `secureboot_status`. Suppression rides on a one-sided marker, `uefiCa2023FirmwareConfirmed`, emitted only on positive confirmation: the engine's `not_exists` precondition fails on any present value, so a two-sided boolean could not distinguish "confirmed" from "checked and missing", while the marker's absence keeps old agents and firmware-missing devices on the v2 path. Rule v3.0.0 is therefore safe to seed before the agent release.
* **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.
-130
View File
@@ -1,130 +0,0 @@
---
type: Reference
title: MCP OAuth Flow — Who Authenticates Where
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.
resource: /src/McpServer/autopilot-monitor-mcp/src/oauth.ts
tags:
- mcp
- oauth
- entra-id
- security
timestamp: 2026-08-29T08:45:00+02:00
---
# MCP OAuth Flow — Who Authenticates Where
**Status:** Reference · **Owner:** MCP Server · **Code:** [`src/McpServer/autopilot-monitor-mcp/src/oauth.ts`](../../src/McpServer/autopilot-monitor-mcp/src/oauth.ts)
## Why this document exists
Connecting an AI client (Claude Desktop, claude.ai, VS Code, ChatGPT) to the
Autopilot Monitor MCP server involves **two unrelated identities** and **three
parties plus Entra ID**. This regularly confuses users during setup, because
the account signed in to the AI product and the account used for Autopilot
Monitor are usually different people-in-one: e.g. **Luke** uses his normal
account `luke` for his Claude subscription, but his admin account `adm.luke`
for Autopilot Monitor.
The two identities never mix:
* **`luke` (AI-product account)** — only authenticates Luke against Claude
itself. Anthropic needs it to associate the OAuth callback with the right
Claude user and connector. The MCP server never sees this identity.
* **`adm.luke` (Entra ID work account)** — the identity *inside* the token.
Every MCP tool call is authorized as `adm.luke`; roles and tenant scope
derive from it. Anthropic never sees this password, only the issued tokens.
## The parties
1. **AI client + its backend** (e.g. Claude Desktop + claude.ai) — the OAuth
*client*. For hosted surfaces the token exchange and token storage happen
in the vendor's backend, not on the user's machine.
2. **MCP server** (Container App) — acts as a stateless **OAuth proxy /
authorization-server façade** in front of Entra ID. It is *not* an IdP:
it establishes the client's identity statelessly — a Client ID Metadata
Document (MCP spec 2026-07-28: the `client_id` is an HTTPS URL the server
fetches and validates) or, for older clients, dynamic registration
(RFC 7591, HMAC-signed stateless `client_id`) — enforces the redirect-URI
allowlist and PKCE, and exchanges codes at Entra using its confidential
client secret. See [Protocol Eras & CIMD](mcp/protocol-eras-and-cimd.md).
3. **Entra ID** — the real identity provider. This is where `adm.luke` lives.
## Full sequence
```
Claude Desktop Browser (user's MCP server Entra ID
+ claude.ai backend DEFAULT browser!) (OAuth proxy)
│ │ │ │
1. │─ POST /mcp ──────────┼───────────────────────▶│ 401 + WWW-Auth │
2. │─ Discovery ──────────┼───────────────────────▶│ RFC 9728/8414 docs │
3. │─ client identity ────┼───────────────────────▶│ CIMD: client_id is a │
│ (CIMD, or legacy │ │ URL, doc fetched at │
│ POST /oauth/ │ │ /authorize; DCR: │
│ register) │ │ HMAC-signed client_id│
│ │ │ │
4. │─ opens browser ─────▶│─ GET /oauth/authorize ▶│─ 302 to Entra ──────▶│
│ │ (PKCE challenge) │ app-reg client_id, │
│ │ │ prompt=select_acct │
│ │ │ │
5. │ │◀── Microsoft sign-in: account picker ────────│
│ │ ➜ user picks adm.luke HERE │
│ │ │ │
6. │ │◀─ 302 /oauth/callback?code=... ──────────────│
│ │─ code ────────────────▶│ verifies HMAC state, │
│ │ │ re-checks allowlist │
│ │◀─ 302 claude.ai/api/mcp/auth_callback?code=..│
│ │ │ │
│◀═ browser hits claude.ai ═════╗ │ │
│ ➜ the browser MUST have an │ │ │
│ active claude.ai session │ │ │
│ as luke — otherwise │ │ │
│ Anthropic cannot map the │ │ │
│ code to a user/connector │ │ │
│ and the connect fails ════╝ │ │
│ │ │ │
7. │─ Anthropic backend: POST /oauth/token ───────▶│─ + client_secret ───▶│
│ (code + PKCE verifier) │◀─ access + refresh ──│
│◀─ tokens (stored server-side at Anthropic, per connector) │
│ │ │ │
8. │─ POST /mcp + Bearer ─┼───────────────────────▶│ validates Entra JWT │
│ │ │ ➜ acts as adm.luke │
```
## Key properties
* **Step 5 vs. step 6 is the double sign-in.** The whole browser leg runs in
the user's *default* browser. That one browser profile needs **both** an
active session at the AI product (`luke` at claude.ai — same account as in
the desktop app) *and* the Entra sign-in as `adm.luke`. `prompt=
select_account` forces the Entra account picker so an existing SSO session
for the wrong account cannot be silently reused.
* **Step 6 is authenticated at the vendor.** `claude.ai/api/mcp/auth_callback`
is an authenticated Anthropic endpoint; the authorization code is bound to
the Claude user via the browser's claude.ai session cookies. No claude.ai
session (or a different browser profile) → the code lands on a login page
and the connect stalls, even though the Entra half succeeded. This is the
single most common setup failure.
* **The MCP server is a pure intermediary.** Its client secret never leaves
the server; the AI client never talks to Entra directly. The redirect
target is checked twice (authorize + callback) against a strict allowlist
of exact vendor callback URIs, and the proxy state that transits the
user-agent is HMAC-signed with a 10-minute lifetime.
* **PKCE is end-to-end.** The AI client generates the verifier; the proxy
forwards the challenge (step 4) and the verifier (step 7) untouched, and
Entra validates the pair. The proxy additionally *requires* S256 PKCE and
rejects downgrades.
* **Tokens are Entra tokens for `adm.luke`** with audience
`api://<client_id>/access_as_user`. For hosted surfaces they are stored in
the vendor's backend per connector (which is why the connector then works
across desktop, web and mobile). Renewal is a silent `refresh_token` grant
through the same `/oauth/token` proxy — no account picker again.
* **Native CLIs skip step 6's hosted callback.** Claude Code, Gemini CLI and
VS Code's loopback flow use RFC 8252 `http://localhost:<port>` /
`http://127.0.0.1:<port>` redirects instead; there the token exchange runs
on the user's machine and no vendor-side session is involved.
## One-line summary
`luke` authenticates the *return path* of the authorization code to the AI
vendor (step 6); `adm.luke` is the identity *inside* the token that the MCP
server authorizes on every tool call (step 8).
-305
View File
@@ -1,305 +0,0 @@
---
type: Concept
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.
resource: /src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts
tags:
- mcp
- search
- embeddings
- documentation
- build
timestamp: 2026-07-21T19:30:00+02:00
---
# Purpose
The MCP server answers two different kinds of question, and they need two
different corpora.
* *"Why did this enrollment fail?"* → analysis rules, gather rules, IME log
patterns. That is `search_knowledge`, backed by `knowledge-base.ts`.
* *"How do I deploy the agent?" / "Where is my data stored?"* → the published
customer documentation. That is `search_docs`, backed by `docs-corpus.ts`.
They are deliberately **separate indexes**. Merged into one, they would compete
for the same `topK` and dilute each other: a rules query would surface prose
pages, and a product question would surface rule definitions.
# Schema
## Corpus source
The corpus is the `autopilotmonitor-docs` repository — a public OKF bundle
published at `docs.autopilotmonitor.com`. It is **not** vendored into this repo.
The deploy workflow checks it out into `mcp-docs/` of the Docker build context;
the Dockerfile copies it to `/app/docs` in both the build and the final stage
(byte-identical, or the boot-time corpus hash rejects the precomputed index).
`SUMMARY.md` and `log.md` are skipped as navigation scaffolding. `index.md` is
kept — it is the documentation map and answers "what documentation exists" — but
gets no `url`, because the bundle deliberately keeps it out of `SUMMARY.md` and
GitBook therefore never renders it.
## Chunking
A whole page cannot be one document: `trust/security-faq.md` is 36 KB, while
MiniLM-L6-v2 truncates at ~256 tokens. An un-chunked page would silently embed
only its first paragraph.
Chunks are cut at **h2/h3 headings**, which in this bundle are also the semantic
boundaries — `security-faq.md` carries 37 h3s, one per question.
`<details><summary>Q</summary>` blocks (used by `troubleshooting/faq.md`) are
treated the same way, with the summary as the section title. Each chunk is
prefixed with a `Page Title › Section › Subsection` breadcrumb so a hit lifted
out of the middle of a page is self-describing to both the embedder and the
reader.
Two deliberate exceptions to the ~1500-character budget:
* **Markdown tables are never split.** A table broken across chunks leaves
`| --- |` rows with no header — unreadable in a result and meaningless to
embed. Tables carry real answers here (the role matrix, the settings
reference), so an over-long table is allowed to exceed the budget.
* **Unbroken lists split at line level.** The changelog pages are one bullet
list per release with no blank lines, so paragraph splitting cannot touch
them; left whole, everything past the model's window would be truncated away
and become unsearchable.
Short sections are **kept**, not filtered. A one-line answer ("Is it free?" →
"Yes, the Community plan is free") is often the most valuable hit, and the
breadcrumb supplies the context the short body lacks. Only structural residue
(under 25 alphanumeric characters) is dropped.
## CRLF is a correctness precondition
The docs repo is authored on Windows and ships CRLF. In JavaScript `.` does not
match `\r`, so a pattern like `/^(#{2,3})\s+(.*)$/` matches **nothing** on a CRLF
file: `(.*)` stops before the `\r` and `$` never reaches end-of-string.
This was observed during development, and the failure mode is what makes it
dangerous: no crash, no error — headings and frontmatter simply stop being
recognized and every page silently degrades to unlabelled paragraph splits. The
first working build produced 286 plausible-looking chunks that were almost all
wrong; after normalizing, `security-faq.md` went from 29 blind splits to 46 real
question sections.
`normalizeNewlines()` runs before any parsing. Do not remove it, and do not
"fix" a future parsing bug here by adding `\r?` to one pattern.
## Token windows: why a chunk can hold several vectors
Chunking at heading boundaries keeps sections coherent, but it does not bound the
result by *tokens* — and it cannot. Measured on this corpus the ratio ranges from
**2.43 to 5.81 characters per token**, a factor of 2.4, so no character budget maps
reliably onto the model's 256-token window. A budget safe for the worst case
(~620 characters) would shred prose and tables alike.
Left alone, 87 of 324 chunks exceeded the window and **13% of all tokens never
reached the ranking** — silently, since the tokenizer truncates without complaint.
The effect was concrete: in `built-in-rules.md :: Apps` (734 tokens), the strings
`ANALYZE-APP-013`, `ANALYZE-OFFICE-001`, `ANALYZE-CORR-003` and `Click-to-Run` all
sit past the cutoff, so *"proxy configuration blocking app downloads"* did not
retrieve that section at all.
So the *chunk* stays whole and the *embedding* is split instead:
`VectorSearchProvider.index()` cuts long text into 250-token windows with 40 tokens
of overlap and embeds each. A document therefore carries `embeddings: number[][]`,
and `search()` scores it by its **best** window.
- **Max, not mean.** Averaging window vectors would blur a long section's several
topics into one mediocre direction — the same dilution that makes large context
windows worse than chunking. Max keeps a document findable by any one thing it
discusses.
- **The tokenizer is a build-time dependency only.** `index()` runs during the
Docker build; production boots from precomputed vectors and never loads it. It is
also only the vocabulary, not the ONNX weights (~93 ms).
- Cost: 324 chunks → 424 vectors (+31%), index 2.63 → 3.12 MB, and 424 instead of
324 dot products per query — still microseconds.
- The rules corpus benefits too, unplanned: 128 rules now carry 172 vectors.
Measured effect: token coverage 87% → **100%**, *"Office Click-to-Run install never
finished"* 0.318 → **0.442**, *"proxy configuration blocking app downloads"* absent
→ **0.559 at rank 1**. The ten reference queries kept byte-identical scores — their
chunks are short, hence one window, hence the same vector as before.
## Keyword fallback
Semantic ranking alone leaves a gap: proper nouns embed poorly. `RealmJoin` occurs
in 3 chunks yet scores under the threshold.
Fuzzy matching is the wrong remedy, and the corpus says so. At a threshold low
enough to surface `RealmJoin` (0.28), Fuse also returns 5 hits for `SCEPman` — a
word this corpus contains **zero** times. Literal containment has neither problem.
So when the semantic pass returns fewer than `topK`, `search_docs` tops the list up
via `lexicalMatch`, the substring scan every provider already exposes:
- Query terms are filtered to distinctive ones (≥4 characters, non-stopword, max 6).
- A hit needs **corroboration**: at least two matching terms, unless the query has
only one. Without this rule, "how do I bake sourdough bread" matched a page about
LLM providers, because "bake" is a substring of "baked into the image".
- Hits are **appended, never merged**: they carry no cosine similarity, so semantic
results keep the head of the list and keyword hits fill the tail, flagged
`matchType: "keyword"` with `keywordFallback` in the response.
### Language
The corpus and `all-MiniLM-L6-v2` are both English-only, and the failure is total
rather than gradual: "wo werden meine Daten gespeichert" returns **zero** results,
not weak ones, because the query vector lands nowhere near any English chunk.
The fix is a sentence in the tool description telling the caller to query in English
and answer in the user's language — the caller is a language model, so translating
the question before the call costs nothing. A multilingual embedding model would
handle it natively but is substantially larger and would need to be measured against
`scripts/eval-docs-search.ts` first, since it changes English retrieval too.
`search_knowledge` carries the same note for the same reason.
### When it fires
Two triggers, and the second one exists because the first was not enough:
1. **Fewer than `topK` semantic hits** — the obvious case.
2. **A weak semantic head** (best score below `WEAK_SEMANTIC_SCORE`, 0.35). Topping
up only a short list misses the more common failure: a pasted identifier like
`WOW6432Node` returns three chunks at ~0.29 that do not contain the word, while
the single chunk that does never gets considered. When nothing semantic is
convincing, exact matches take the front — keeping at least one semantic result,
so a weak-but-correct hit is never pushed out entirely.
The threshold was chosen by measurement, not intuition. `scripts/eval-docs-search.ts`
scores strategies over 2465 queries in three classes — every chunk heading
(known-item, a regression detector), rare identifiers (the class this fallback
serves), and hand-written questions (the reality anchor, not derived from the
corpus):
| strategy | known-item | literal | handwritten MRR | fixed | broken |
| --- | --- | --- | --- | --- | --- |
| append-only (previous) | 71.9% | 66.2% | 1.000 | — | — |
| weak < 0.30 | 71.9% | 72.6% | 1.000 | 142 | 0 |
| **weak < 0.35** | **73.7%** | **81.1%** | **1.000** | **335** | **0** |
| weak < 0.40 | 76.3% | 88.2% | 0.944 | 502 | 3 |
| weak < 0.45 | 77.2% | 93.0% | 0.889 | 613 | 5 |
0.35 is strictly dominant: better in every class, worse in none. Higher thresholds
keep repairing more identifier queries but start demoting hand-written questions —
the class that most resembles real use — so the gain is not free past that point.
The measurement also corrected the size of the problem: identifier queries were
succeeding only 66.2% of the time, so `WOW6432Node` was not an outlier but one of
roughly 750 such failures.
## Build-time embedding
Like the rules corpus, docs are embedded once at Docker build time by
`precompute-embeddings.js <rulesDir> <outFile> [docsDir]`, into a third
`PrecomputedSection` (`docs`) of `search-index.json`. The container never embeds
a corpus at boot — only the incoming query.
The `docs` section is **optional** in the file format, which is what makes a
local checkout without the docs repo work: `validatePrecomputedIndex` skips it
when the booting server loaded zero doc chunks. The asymmetry is intentional —
if the server *has* a docs corpus and the file does *not*, that is staleness and
is rejected like any other mismatch.
Embeddings are rounded to 6 decimals on serialize. A float64 component
serializes to ~20 characters of JSON, so 384 dims cost ~7.8 KB per vector;
rounding roughly halves the index file, which is `JSON.parse`d on every
scale-to-zero cold start. The induced L2 norm error (~1e-5 over 384 dims) sits
two orders of magnitude inside `UNIT_NORM_EPSILON`.
## Registration
`search_docs` is registered for **every role**, unlike the platform tools: this
is public documentation carrying no tenant data, and a tenant user asking "how do
I deploy the agent" has as much business here as a Global Admin. It is skipped
entirely when the corpus is empty, so a doc-less build advertises no broken tool.
# Cold start
`minReplicas: 0` means every idle period ends in a cold start someone waits through.
Measured from outside: **24.5 s** to first byte after `ScaledToZero`, against **60 ms**
warm.
Where those seconds go could not be answered from outside the process. Log Analytics
stamps every console line of one ingestion batch with the same time, so the boot
sequence collapses to a single instant however it is queried. The server therefore
times itself — `[boot +Nms]` marks from `process.uptime()`, which includes Node's own
startup and module loading.
Measured in production on a real cold start (31.5 s to first byte that time):
```
+244ms node started (startup + module loading)
+431ms corpora read from disk (+187ms)
+491ms index read, validated, hydrated (+60ms <- for 3.12 MB)
+504ms listening
+677ms query embedder warm (background)
```
**The application accounts for half a second of it.** The other ~31 s — 98% — is
Container Apps activation: KEDA reacting to the request, scheduling a pod, mounting
the 236 MB image, starting the container. The container also boots *faster* than a
development laptop (504 ms against 1273 ms), so nothing here is CPU-starved.
Three conclusions, and two of them overturn assumptions this file previously stated:
- **Index size is irrelevant to startup.** 3.12 MB hydrates in 60 ms. Growing the
corpus is effectively free, and the embedding rounding earns its keep on file size
alone, not on boot time.
- **Raising the container to 0.5 vCPU was not justified by cold start.** The stated
reason was the embedding model load; prebaked, that is 173 ms and runs in the
background, where only the first *search* could ever wait on it. The extra vCPU may
still help concurrent requests, but the cold-start argument does not survive
measurement.
- **The only real lever is `minReplicas: 1`**, which removes activation entirely at
roughly 7× the monthly free consumption grant. Shrinking the image would shave the
mount, but that is a fraction of platform time, not the bulk of it.
# Cross-repo coupling
**Editing the documentation does not update a running MCP server.** The corpus is
baked into the image, so only re-running the *Deploy MCP Server* workflow picks
up doc changes.
That coupling is made checkable rather than memorable: the build passes the docs
checkout SHA as `--build-arg DOCS_COMMIT`, and `/health` reports
`docs: { commit, chunks, sections }`. Comparing that commit against
`git rev-parse HEAD` in `autopilotmonitor-docs` answers "is the deployed
documentation stale?" directly.
Automatic redeploy on a docs push was considered and rejected: it would need a
PAT secret in the docs repo and would turn every typo fix into a container
deployment.
# Examples
Retrieval measured against the real bundle (323 chunks, all-MiniLM-L6-v2):
```
"where is my data stored" → 0.566 troubleshooting/faq.md :: General › Where is my data stored?
"how do I deploy the agent with Intune" → 0.819 troubleshooting/faq.md :: Setup & Agent › How do I deploy the agent?
"which roles exist and what can they do" → 0.500 trust/security-faq.md :: Identity and Access › What roles exist?
"how do I set up Teams notifications" → 0.592 reference/settings.md :: Tenant › Notifications
"what firewall URLs do I need to allow" → 0.374 reference/network-endpoints.md :: (intro)
```
The 0.25 `minScore` default is tuned to that observed band — short keyword
queries on all-MiniLM score low, and a relevant-but-marginal hit lands around
0.34.
# Citations
* `/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts` — loader, frontmatter
parser, chunker
* `/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts` — `search_docs`
registration
* `/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts` — `docs`
section and its validation asymmetry
* `/src/McpServer/autopilot-monitor-mcp/Dockerfile` — `mcp-docs/` copy,
`DOCS_COMMIT`
* `/.github/workflows/deploy-mcp.yml` — docs checkout and SHA capture
* `/src/McpServer/autopilot-monitor-mcp/scripts/eval-docs-search.ts` — retrieval
evaluation harness; run it before changing anything about ranking
* [MCP OAuth Flow](../mcp-oauth-flow.md) — the other MCP concept document
-129
View File
@@ -1,129 +0,0 @@
---
type: Concept
title: MCP Protocol Eras & Client ID Metadata Documents
description: How the MCP server serves the 2026-07-28 stateless revision and 2025-era clients from one per-request factory, why the legacy leg is hand-wired (JSON + gzip), the cache-hint and capability contract, and the CIMD client-registration path next to the deprecated dynamic registration.
resource: /src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts
tags:
- mcp
- protocol
- oauth
- cimd
- security
timestamp: 2026-08-29T08:40:00+02:00
---
# MCP Protocol Eras & Client ID Metadata Documents
**Status:** Concept · **Owner:** MCP Server · **Code:** [`mcp-http.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts), [`mcp-server-factory.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts), [`cimd.ts`](../../src/McpServer/autopilot-monitor-mcp/src/cimd.ts), [`oauth.ts`](../../src/McpServer/autopilot-monitor-mcp/src/oauth.ts)
# Schema
## The two eras
MCP revision **2026-07-28** turned the protocol into a stateless request/response
protocol: no `initialize` handshake, no `Mcp-Session-Id`, every request carries its
protocol version, client identity and capabilities in `_meta`, capabilities and
instructions are fetched with `server/discover`, list results carry `ttlMs` /
`cacheScope`, and `Mcp-Method` / `Mcp-Name` HTTP headers let gateways route without
parsing bodies. Roots, Sampling, Logging and the HTTP+SSE transport are deprecated
(twelve-month window); Dynamic Client Registration is deprecated in favour of Client
ID Metadata Documents.
The server was already stateless by design (scale-to-zero Container App, no
server→client traffic), so the revision cost no architecture — only wiring:
| Concern | 2026-07-28 ("modern") | 2025-era ("legacy") |
| --- | --- | --- |
| Routing decision | `isLegacyRequest()` from the SDK — the exact classifier `createMcpHandler` uses internally | same predicate, inverse |
| Serving | `createMcpHandler(factory, { legacy: 'reject', responseMode: 'json' })` via `toNodeHandler` | fresh `NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })` per POST |
| Response shape | single `application/json` body | single `application/json` body |
| Instructions | `server/discover` result | `initialize` result |
| Cache fields | `ttlMs` + `cacheScope` stamped by the SDK from `cacheHints` | none (2025 codec has no cache path) |
Both legs are fed by **one** factory (`createServerForCaller`), so the tool catalog,
resource set, prompts and instructions can never drift between eras — the
`mcp-http.test.ts` suite pins that a Global Admin and a plain tenant user see the
same catalog on either path.
## Why the legacy leg is hand-wired
`createMcpHandler`'s built-in `legacy: 'stateless'` fallback constructs its transport
with only `sessionIdGenerator: undefined`, i.e. it answers every 2025 client with an
SSE frame. Every production client today (Claude Code, Claude.ai, VS Code) is a 2025
client, and their large tool results are gzipped by the compression middleware only
because the response is a plain JSON body (measured 5–30×). Routing on the SDK's own
predicate in user land keeps that behaviour while still running the 2026 path through
the SDK entry unchanged — no re-implementation of the classification, no second
catalog.
## Capability and cache contract
* `capabilities` are declared explicitly with `listChanged: false` for tools,
resources and prompts. The SDK's default (`?? true`) would advertise a
notification a per-request server can never emit.
* `cacheHints`: `server/discover`, `tools/list`, `prompts/list`, `resources/list`,
`resources/templates/list` → `{ ttlMs: 300_000, cacheScope: 'private' }`;
`resources/read` → `{ ttlMs: 3_600_000, cacheScope: 'private' }`. **Never
`public`**: the catalog and the instructions are role-dependent (Global Admin /
Global Reader / delegated MSP / tenant user), so a shared cache would leak one
caller's surface to another. Five minutes is the bound on how long a role change
can go unnoticed by a caching client; resources are static per deployment.
* No Roots / Sampling / Logging / elicitation / subscriptions are used anywhere, so
nothing on the deprecation list has to be migrated.
## Client registration: CIMD first, DCR as fallback
The authorization-server metadata now advertises
`client_id_metadata_document_supported: true` next to the (deprecated)
`registration_endpoint`. Clients following the spec's priority order use a
**Client ID Metadata Document**: their `client_id` *is* an HTTPS URL that serves a
JSON document (`client_id`, `client_name`, `redirect_uris`, …). `oauth.ts` resolves
every `client_id` through one helper: an HTTPS URL goes to `cimd.ts`, anything else
must be one of our HMAC-signed dynamic-registration tokens. Both yield the same shape
(`redirectUris`), and the rest of the authorize/callback logic is unchanged.
What the document decides — and what it does not:
* It only asserts **which** redirect URIs the client claims. The requested
`redirect_uri` must still pass the host/path allowlist that gates dynamic
registration (`isAllowedRedirectUri`), checked *before* the document is fetched. A
self-hosted document cannot widen the destinations an authorization code may be
sent to. Loopback remains allowed for every client (RFC 8252 §7.3);
`application_type: "native"` is informational.
* Fetching a caller-chosen URL is an SSRF surface, so the fetch is: HTTPS only, path
component required, no fragment/userinfo, no loopback or IP-literal host, every
resolved address must be public (RFC 1918/4193/3927/6598, CGNAT, multicast,
IPv4-mapped all refused — checked before any request leaves), `redirect: 'error'`,
5 s budget, 16 KB body cap (declared *and* streamed), JSON media type required.
Nothing from the response reaches the caller beyond an OAuth error code.
* The document's `client_id` must equal the URL by simple string comparison — no
normalization, per the draft. `redirect_uris` is bounded exactly like a dynamic
registration (`oauth-limits.ts`). A missing `client_name` falls back to the host:
it only ever reaches a log line, and failing a login over a label would be wrong.
* Cache: positive entries live for the document's `Cache-Control: max-age`, clamped
to 10–60 min so the `/oauth/callback` re-check within the 10-min signed-state
window is a cache hit on the same replica; rejections are cached for 60 s; the map
is bounded to 256 entries.
RFC 9207 `iss` (already emitted since 2026-07-29) completes the authorization side
of the 2026-07-28 revision.
# Examples
* A 2026 client: `POST /mcp` with `_meta["io.modelcontextprotocol/protocolVersion"] = "2026-07-28"`
and `Mcp-Method: tools/list` → `{ tools: [...], ttlMs: 300000, cacheScope: "private", _meta: { "io.modelcontextprotocol/serverInfo": { name: "Autopilot-Monitor", version: "1.6.<build>" } } }`.
* A 2025 client: `POST /mcp` `initialize` → `application/json` body with
`protocolVersion: "2025-11-25"` and the role-tailored `instructions`; no
`Mcp-Session-Id` header.
* `GET /oauth/authorize?client_id=https://app.example.test/oauth/client.json&redirect_uri=http://127.0.0.1:49152/callback&code_challenge=…` →
document fetched (or served from cache) → 302 to Entra. `client_id=https://10.0.0.8/x` → `400 invalid_client`
without any outbound request.
# Citations
* MCP spec 2026-07-28 — https://blog.modelcontextprotocol.io/posts/2026-07-28/ ; authorization / client registration:
https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration
* TypeScript SDK v2 (`@modelcontextprotocol/server` 2.0.0) — `createMcpHandler`, `isLegacyRequest`, `cacheHints`.
* draft-ietf-oauth-client-id-metadata-document-00 §6 (security considerations, SSRF).
* Tests: `src/__tests__/mcp-http.test.ts`, `src/__tests__/cimd.test.ts`, `src/__tests__/oauth-cimd.test.ts`.
* Related: [MCP OAuth Flow](../mcp-oauth-flow.md), [MCP Docs Corpus](docs-corpus.md).
-84
View File
@@ -1,84 +0,0 @@
---
type: concept
title: MCP Tool Telemetry
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
resource: src/McpServer/autopilot-monitor-mcp/src/telemetry.ts
tags: [mcp, telemetry, observability, tool-quality]
timestamp: 2026-08-05
---
# Schema
Three layers answer "are the MCP tools doing their job, and are tools missing?":
1. **Backend App Insights** — every tool handler runs inside `withToolTelemetry`
(`src/telemetry.ts`), which propagates the tool name via AsyncLocalStorage so
`apiFetch` stamps `X-MCP-Tool-Name` on each backend request.
`RequestTelemetryMiddleware` copies it into the request telemetry as
`McpToolName`: per-tool call volume, latency percentiles and HTTP failure
rates by KQL. Blind spot: tools that never call the backend, and handler-level
soft errors (see layer 3).
2. **Usage table** — `McpQuotaEnforcementMiddleware.TrackUsage` counts per
user/day under the key `toolname:endpoint`; surfaced by the `get_api_usage`
tool. Counts only, every call counts as success.
3. **stderr JSON lines** (`MCP_TOOL_LOGGING=true`, queryable via Container App
Logs) — the layer that carries what the other two structurally cannot:
- `tool_call`: duration, `isError` **including soft errors** (handlers never
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
records those calls as successes), `errorMessage` (capped; the thrown
message or the first lines of the `toolError` text, which name the error
class — HTTP status / timeout / auth / not found; absent on success),
`resultChars` + `overCap` (result
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
it; a frequently-overCap tool needs tighter defaults or projections), a
size-bounded args summary, and the caller scope (ga/delegated/tenant).
This is the ONLY telemetry for backend-free tools: `search_docs`,
`validate_rule`, `get_resource`, the local ranking of `search_knowledge`.
- `search_zero_hit`: query text (capped) whenever `search_events` /
`search_knowledge` / `search_docs` return zero results — the most direct
"missing docs / missing knowledge / missing tool?" demand signal.
- `tool_call_rejected`: rejections the SDK answers *before* any handler runs
(Zod argument validation, unknown/disabled tool). Two non-obvious facts,
both found by live verification: SDK 1.30 wraps these McpErrors as a
*successful* JSON-RPC response whose result is `{ isError: true, text:
"MCP error <code>: …" }` — a JSON-RPC error envelope never exists for
them — and the Hono bridge (`getRequestListener`) may stream the body as
`res.write` chunks with a bare `res.end()`. The sniffer in the `/mcp`
route therefore wraps both `write` and `end`, reassembles up to 10 KB and
matches both shapes (`attachToolCallRejectionSniffer` in `telemetry.ts`).
Handler soft errors never carry the `MCP error` prefix, so they are not
double-logged. This is the strongest "the schema or description confuses
the model" signal and was previously invisible everywhere.
The env flag defaults to `true` in `infra/mcp-server.bicep` (documented desired
state — the template is not routinely deployed; the live flag is set with
`az containerapp update --set-env-vars MCP_TOOL_LOGGING=true`).
# Examples
Count zero-hit queries to find unmet demand (Log Analytics):
```kusto
ContainerAppConsoleLogs_CL
| where Log_s has '"type":"search_zero_hit"'
| extend d = parse_json(Log_s)
| summarize count() by tostring(d.tool), tostring(d.query)
```
Per-tool soft-error and truncation rates:
```kusto
ContainerAppConsoleLogs_CL
| where Log_s has '"type":"tool_call"'
| extend d = parse_json(Log_s)
| summarize calls = count(), errors = countif(d.isError == true),
overCap = countif(d.overCap == true), p95ms = percentile(tolong(d.durationMs), 95)
by tool = tostring(d.tool)
```
# Citations
- `src/McpServer/autopilot-monitor-mcp/src/telemetry.ts` — wrapper + log emitters
- `src/McpServer/autopilot-monitor-mcp/src/index.ts` — `/mcp` rejection sniffer
- `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `McpToolName` stamping
- `src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs` — usage-table counting
-122
View File
@@ -1,122 +0,0 @@
---
type: Concept
title: Analyze Rule Evaluation Triggers (evaluateOn)
description: "Opt-in interim evaluation of analyze rules before a session is terminal — enrollment_end stays the default, whiteglove_sealed and on_event:<type> triggers close the WhiteGlove notification gap and the never-terminal stuck-session gap with one mechanism, backed by update-semantics on RuleResults instead of the permanent dedupe freeze."
resource: backend/rule-engine
tags: [rules, analyze, triggers, notifications, whiteglove]
timestamp: 2026-08-14
---
# Status
**IMPLEMENTED 2026-08-14** (same day the spec was approved). The open product decisions below were confirmed as "no" for v1. Key implementation anchors: `AnalyzeRuleTriggers` (Shared — grammar + matching helpers, shared by engine, ingest registry and CRUD validation), `AnalyzeRunContext` / `AnalysisOutcome.ResolvedResults` (RuleEngine), `InterimTriggerRegistry` (5-min-TTL per-tenant cache for the ingest hot path, fail-soft), `AnalyzeOnEnrollmentEndHandler` interim branch, `EvaluateOnJson` + five RuleResult lifecycle columns in `TableStorageService.Rules`. ANALYZE-ID-004 ships as the first interim-enabled rule (`on_event:hybrid_login_pending`, repetition-gated at threshold 75).
# Problem
Analyze rules run at exactly three points today, all of them terminal or near-terminal:
1. `enrollment_complete` / `enrollment_failed` observed at ingest (`EventIngestProcessor.cs`, classification → analyze envelope).
2. The maintenance sweep terminalizing a silent session (Failed / Incomplete + synthetic `session_timeout`, `MaintenanceService.cs`).
3. The vulnerability-correlation rerun (`vulnerability_correlated` reason — incremental, no stats).
Two incident classes fall through this model:
- **WhiteGlove gap (sits-d, 2026-08-11):** `whiteglove_complete` seals a session to Pending but enqueues no analyze run. A rule that should alert at the end of pre-provisioning (technician still at the bench) never fires, and when Part 2 eventually terminalizes days later, the permanent per-(session, rule) dedupe means a finding that sneaked in earlier (e.g. via the vuln-rerun backdoor) is frozen and never notifies.
- **Never-terminal stuck sessions (esa-logistics, 2026-08-13/14):** a session looping in Account Setup (user signs in daily, hybrid affinity never establishes) resets the 5h inactivity clock on every logon. It is never terminalized, therefore never analyzed — the exact sessions where a diagnosis would help most (ANALYZE-ID-004, ANALYZE-APP-015) produce no finding while the customer watches the device loop.
Both gaps share one root cause: *evaluation time is hard-wired to session end*. Gather rules already solved the analogous problem on-device with declarative triggers (`startup` / `phase_change` / `phase_exit` / `interval` / `on_event`); analyze rules need the server-side equivalent.
# Design Overview
Add an optional `evaluateOn` array to the analyze-rule schema. Absent field ⇒ `["enrollment_end"]` ⇒ **today's behavior, bit-for-bit, for every existing rule**.
```
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"]
```
Trigger grammar (v1):
| Trigger | Fires when | Notes |
|---|---|---|
| `enrollment_end` | terminal analyze run (complete / failed / sweep-terminalized) | default; also the **finalization pass** for interim results (below) |
| `whiteglove_sealed` | ingest classifies the first genuine `whiteglove_complete` (session → Pending, `isPreProvisioned`) | duplicate seals do not re-trigger |
| `on_event:<eventType>` | an ingest batch contains ≥1 event of `<eventType>` | `<eventType>` validated against the event-types catalog; phase boundaries are covered by `on_event:phase_transition` / `on_event:esp_phase_changed` — no separate phase grammar needed, since every run evaluates the full stream anyway |
Explicitly **not** in v1: interval/timer triggers (no cheap host for them; the sweep remains the only clock), and `on_event` filters on data fields (the rule's own conditions do that — the trigger only decides *when* to look).
# Pipeline Changes
## Ingest (producer side)
- `EventIngestProcessor` already classifies each stored batch. After classification, intersect the batch's event types with the tenant's **trigger registry** (the set of `on_event` types across the tenant's active rules — derivable from `AnalyzeRuleService`'s merged rule load, cached alongside it). Non-empty intersection ⇒ enqueue one envelope `Reason=interim_trigger`, carrying the matched trigger event types. Reuse the existing 30s visibility delay (terminal-flush ordering argument in `AzureQueueAnalyzeOnEnrollmentEndProducer` applies to interim batches too).
- The `whiteglove_complete` seal path (`EventIngestProcessor.Classification.cs`) enqueues `Reason=whiteglove_sealed` — only on the first seal, only for `isPreProvisioned`.
- Terminal enqueue (`CompletionEvent` / `FailureEvent`) is unchanged.
## Handler / engine (consumer side)
`AnalyzeOnEnrollmentEndHandler` branches by reason today (primary vs. `vulnerability_correlated`); interim reasons become a third branch. The engine call gets a run context instead of the bare `reanalyze` flag:
| Aspect | Terminal run (`enrollment_end`) | Interim run (`whiteglove_sealed`, `interim_trigger`) |
|---|---|---|
| Rule set | ALL active rules (interim-triggered ones get their finalization pass here) | only rules whose `evaluateOn` matches the reason / one of the batch's trigger types |
| KO (`MarkSessionAsFailed`) | applies (unchanged) | **suppressed** — a Pending/InProgress session must never be flipped Failed by an interim pass (`TryMarkSessionFailedFromRuleAsync` is skipped; the terminal run re-fires the rule and applies KO then) |
| Platform stat `IssuesDetected` | counted | skipped (like vuln rerun) |
| Per-rule fire stats | counted | skipped (like vuln rerun) — keeps the terminal-only stats convention and the Rule Regression Radar baselines clean |
| Channel notifications | new findings notify (unchanged) | new findings notify — **this is the point** of both gaps |
| SignalR results-available | yes | yes (live update of an open session page) |
## Result lifecycle: update semantics replace the dedupe freeze
Today the engine skips any rule that already has a stored `RuleResult` row for the session (`RuleEngine.AnalyzeSessionAsync`, existing-results lookup) — findings are frozen at first write, which is precisely the WG-gap failure mode ("dedupe permanent").
New model — `RuleResult` gains four fields (`FirstDetectedAt`, `LastEvaluatedAt`, `IsInterim`, `ResolvedAt`, plus `NotifiedAt` for the notification marker):
- **Interim run:** rules in scope are (re-)evaluated even when a row exists. Fired ⇒ upsert (evidence/confidence refreshed, `FirstDetectedAt` preserved, `IsInterim=true`). Not fired + existing interim row ⇒ `ResolvedAt` set (kept for audit; excluded from issue counts and default UI).
- **Terminal run:** every rule that has an interim row is re-evaluated regardless of dedupe (finalization). Fired ⇒ row finalized (`IsInterim=false`, evidence refreshed — fixes the stale-template-field freeze, e.g. the ESP-004 `{{appName}}` case). Not fired (typically a `not_exists` precondition on `enrollment_complete` now failing because the session healed) ⇒ `ResolvedAt` set. Rules without interim rows keep today's skip-if-exists dedupe.
- **Notification dedupe** moves off "row exists" onto `NotifiedAt` on the row: notify once per (session, rule) at first fire — interim or terminal, whichever comes first; refreshes and finalization do not re-notify. The manual `reanalyze=true` path must **preserve** `NotifiedAt` across its rebuild (today it deletes rows; delete-and-recreate would silently re-arm notifications) and keeps its designed never-notify behavior.
- Legacy rows (null new fields) read as final/terminal — no migration.
# Authoring Guardrails (validate_rule lints)
Interim evaluation changes what a rule's conditions mean mid-flight. Three new lints in the MCP `validate_rule` pre-flight (and the baked authoring guide):
1. **Terminal-precondition trap (warning):** `evaluateOn` contains an interim trigger AND a precondition is `not_exists` on `enrollment_complete` / `enrollment_failed` / `session_timeout`. Mid-run these pass trivially — the rule needs conditions that are *monotonic* (once true on the growing event stream, stays true) or repetition-gated. Worked example: ANALYZE-APP-015 must NOT get an interim trigger as-is — even successful sessions transiently show its "completion signal deferred" + `pending>=1` pattern (the terminal precondition is its only gate). ANALYZE-ID-004 is interim-safe by construction (threshold 65 requires repeated `hybrid_login_pending`).
2. **Unknown trigger event type (error):** `on_event:<type>` not in the event-types catalog.
3. **KO-on-interim (warning):** `markSessionAsFailedDefault: true` on a rule whose only triggers are interim — the KO would never apply until terminal; say so.
# Cost & Safety
- Interim runs are bounded by trigger frequency, and the chosen v1 triggers are rare by construction (`hybrid_login_pending` ≤1 per boot, `whiteglove_sealed` once per session). High-frequency telemetry event types (performance_snapshot, agent_metrics_snapshot, download_progress, network churn, log_entry, agent_trace, stall_probe_check) are **hard-blocked** as `on_event` triggers, not merely advised against: the CRUD write is rejected (`AnalyzeRuleTriggers.BlockedOnEventTypes`, code-only enforcement), the runtime matcher drops them from any row that slipped in (defense in depth — the trigger registry and the engine both go through `OnEventTypes`), and the MCP lint reports them as an error. The JSON mirror lives in `rules/guardrails.json` (`blockedInterimTriggerEventTypes`) with a parity test pinning it to the C# set — the same pattern the gather hard blocks use. If a legitimate rare-but-bursty type ever hurts, a per-(session, reason) throttle marker is the v2 lever.
- Each run loads the full event stream once (existing engine behavior, no change); rule filtering makes interim passes strictly cheaper than terminal ones.
- Queue semantics (retry via visibility timeout, poison after MaxDequeueCount, idempotent partial-success re-runs) are inherited unchanged; upserts keep idempotency.
# Surfaces
- **Web session page:** interim findings render live (existing SignalR results-available notification) with a "preliminary" badge on `IsInterim`; `ResolvedAt` rows hidden by default behind a toggle.
- **MCP:** `get_session(includeAnalysis)` / `get_session_summary` expose `isInterim` / `resolvedAt`; `list_session_annotations`' fired-rule snapshot semantics unchanged (labels bind to whatever was fired at annotation time).
- **Success-rate / platform stats:** terminal-only convention untouched (interim runs record no stats).
# Application to the Motivating Cases
- WG battery rule (sits-d): shipped 2026-08-21 as ANALYZE-DEV-009 (battery below 15% while on battery) and ANALYZE-DEV-010 (AC→battery switch), both `evaluateOn: ["enrollment_end", "whiteglove_sealed", "on_event:power_state_change"]` on the live power watcher's `power_state_change` events (see `../agent/power-state-watcher.md`) — technician gets the channel alert at the bench; the terminal pass finalizes the finding. Both are deliberately single-required-condition rules: `event_data` conditions scan events independently (no same-instance join), so DEV-009 matches the agent-stamped `thresholdPercent` and DEV-010 matches `transition`.
- Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1).
- Stuck after a forced mid-ESP reboot (sits-d Cloud PCs, 2026-08-19): `on_event:session_stalled` is the established trigger for the never-terminal stuck session. The agent emits `session_stalled` exactly once per session (60-minute stall probe), so the trigger is rare by construction and needs no throttle. Five Cloud PCs sat at `InProgress` with **zero findings** for hours — a mid-ESP reboot had killed the agent in `Stage=EspAccountSetup` — before the max-lifetime watchdog eventually terminalised them as `Incomplete`. ANALYZE-ESP-005 carries the trigger and fires on that stream at confidence 95 (verified against session `8110e262`): both its conditions (`mdm_policy_reboot_required.firstRebootUri` exists, `system_reboot_detected` count ≥ 1) are **monotonic** and it has no terminal preconditions, so lint 1 does not apply. ANALYZE-APP-016 is interim-safe for the same reason (`app_install_starved` count ≥ 1 only ever grows).
Two rules were considered and deliberately **not** given the trigger: ANALYZE-APP-015 is the worked example of lint 1 above and stays `enrollment_end`-only; ANALYZE-ESP-001 gates on `phase_duration` of a phase that is still open mid-run, and that evaluation is not pinned by a test — an unverified trigger is not worth a possible false positive on every slow DeviceSetup.
# Product Decisions (settled 2026-08-14)
1. Notify-on-resolve (a "finding healed" message)? **No** (v1) — resolution is visible in UI/MCP.
2. Re-notify on severity/confidence escalation of an existing finding? **No** (v1) — one notification per finding.
3. `whiteglove_sealed` runs allowing KO rules to flip Pending → Failed? **No** — the Pending state is the WhiteGlove contract; suppression is unconditional for interim.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` — terminal-only enqueue (classification → analyze envelope).
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs` — reason branching, notify path, stats side-effects.
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs` — 30s visibility delay rationale.
- `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs` — existing-results dedupe, `TryMarkSessionFailedFromRuleAsync` KO path.
- `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — sweep terminalization + analyze enqueue; inactivity-clock reset explains the never-terminal gap.
- `rules/gather-rule-phase-scoping.md` (this bundle) — the on-device trigger precedent this design mirrors.
- `rules/rule-authoring-surface.md` (this bundle) — where the new lints land (validate_rule + baked guide).

Some files were not shown because too many files have changed in this diff Show More