Compare commits

...

106 Commits

Author SHA1 Message Date
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
Oliver Kieselbach 8ec8524252 fix(security): bind platform and delegated roles to the immutable Entra tid+oid, not the UPN string
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.

- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
  CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
  in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
  DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
  (no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
  the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
  homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
  GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
  longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
  recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md

Legacy role rows are inert until their binding is seeded - seed before deploying the backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 18:35:47 +02:00
Oliver Kieselbach c4be6d4169 fix(security): block javascript:/data: URLs in analyze-rule relatedDocs (stored XSS)
Author-controlled relatedDocs[].url was rendered verbatim as <a href> on the session
detail, diagnosis and analyze-rules pages and never validated server-side, so a
Tenant Admin could store a javascript: URL that executes in the portal origin for
any viewer — including cross-tenant Global/MSP admins.

- Web: safeHttpUrl() guard (absolute http/https only) at all four render sites;
  anything else renders as plain text.
- Backend: ValidateRelatedDocs on Create/Update — blank URLs dropped, non-http(s)
  URLs rejected with 400.
- Tests on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:55:50 +02:00
Oliver Kieselbach d558b94cf6 updated commands 2026-08-29 17:24:45 +02:00
Oliver Kieselbach 9b4222e729 fix(ime-archive): verify MSI ProductVersion and walk primary/secondary/hotfix hosts before archiving
Microsoft serves IME from several versionless hosts carrying different rings at once
(secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the
canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools
each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no
msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later
sighting with a version-authoritative CSP URL re-queues an unarchived version at most
once per 24 h (MsiArchiveUpdatedAt + Queued stamp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:23:05 +02:00
Oliver Kieselbach cf76c4338f feat(vuln): manual "Refresh NVD Cache" and "Sync EPSS" with their own last-sync lines
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS
re-score ran on the nightly timer alone. Both walks move out of the timer into
VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the
nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for
a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the
minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the
page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST
vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a
manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new
AdminConfiguration fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:57:55 +02:00
Oliver Kieselbach a6184f165b feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.

NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.

CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:07:31 +02:00
Oliver Kieselbach d3918cb740 feat(mcp): log the protocol era per request (mcp_request line, MCP_TOOL_LOGGING-gated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:13:00 +02:00
Oliver Kieselbach 15722a3885 fix(mcp): log the CIMD success path so metadata-document logins are positively visible
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:06:09 +02:00
Oliver Kieselbach 1a4e13f0e9 feat(mcp): implement MCP spec 2026-07-28 (SDK v2 dual-era handler) and Client ID Metadata Documents
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0
(zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras
through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode,
server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with
enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless
fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare
listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results
(5 min) and resource reads (60 min) — the catalog is role-dependent, so never public.

Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID
Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any
request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache).
The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR
client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle).

Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity;
cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths.
Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 08:43:58 +02:00
Oliver Kieselbach c39092bf72 ci(mcp): image tag unique per run so docs-only redeploys roll a new revision
A redeploy at an unchanged code sha (the documented way to refresh the
baked-in search_docs corpus) built an image with the identical tag;
az containerapp update saw the same image reference and created no
revision, so the verify step timed out against the old build (run
33215583943). Tag = sha-run_number; build commit/version stay separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:17:04 +02:00
Oliver Kieselbach 494744ad3f feat(web): progress portal follows Windows 365 Cloud PC first-connect enrollments
Cloud PC users know the device name the Windows App shows, and the lookup
already accepts device names, so there is no reason to exclude them. The
monitored enrollment starts at first connect in Account Setup (v1 rail,
phases 4 -> 5 -> 7; device phases 1-3 run headless and are never declared),
so the Cloud PC layout lists only the steps the user can actually watch:
First sign-in / Account setup / Installing apps / Finalizing setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:05:59 +02:00
Oliver Kieselbach a68c096a26 feat(web): progress portal follows every enrollment scenario (Device Preparation, self-deploying, pre-provisioning)
The end-user page hardcoded the seven Autopilot v1 ESP phases and gated its live
app panel on currentPhase 3/5 — phases a Device Preparation session never
declares — so DevPrep users saw 0 % and no app progress until Finalizing.
Steps now derive from the shared resolvePhaseLayout (v2 layout, skip-user
layout), the v2 app step is promoted from app-event evidence, and the status
vocabulary maps AwaitingUser / parked pre-provisioned devices to a waiting
state and Incomplete to neutral — only Failed stays red. Cloud PCs are named
as unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:30:04 +02:00
Oliver Kieselbach bf156cf862 fix(backend): CMTrace skew tripwire ignores backlog-replay bursts via per-sample grid conformity
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case
was a relaunched agent re-tailing the IME log and uploading one burst holding
most of the session's IME samples with a continuum of ages whose median hit the
15-minute grid by chance. The tripwire now also requires >= 80 % of the IME
deltas (relative to the other side's median) to individually sit within 2 min of
some grid multiple; era-mixing regressions still fire. Fraction is carried in the
ops event message and details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:42:51 +02:00
Oliver Kieselbach 0eaa3527f9 feat(validation,web): device association validator generally available
Microsoft released Windows Autopilot device association on 2026-08-25. The
DevPrep association lookup leaves shadow mode and becomes an accepting method
of the device-validation gate (after Autopilot and Corporate Identifier, before
the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The
toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields
dropped, settings toggle shown to every tenant admin under Device Preparation
with the shared consent rule. Landing hero carries a release pill linking to
the docs; dashboard tip added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 21:20:57 +02:00
Oliver Kieselbach 5055db2de1 feat(rules): detect installer-mutex app failures (0x80070652) and SWG-blocked diagnostics uploads
Two analyze rules born from the pilatus-aircraft diagnosis (2026-08-27):

- ANALYZE-APP-017 "App Install Blocked by Another Installer (0x80070652)":
  fires on app_install_failed with hresultFromWin32 -2147023278
  (ERROR_INSTALL_ALREADY_RUNNING); confidence boosts for MSI exit code
  1618 and for an ESP failure settling with the same code. The 08-26
  burst failed 16 sessions deterministically at ~21 min (IME's fixed
  msiexec-busy retry budget) right after a preceding installer left the
  Windows Installer mutex held - previously only generic
  "Enrollment Failed" fired, naming no cause.
- ANALYZE-NET-001 "Diagnostics Upload Blocked by Web Security Gateway":
  fires on diagnostics_upload_failed answered by an HTML block page
  carrying a recognizable security-product marker (McAfee/Skyhigh,
  Zscaler, Netskope, ...). OnFailure diagnostics are lost exactly on the
  sessions that need them; plain HTML errors without a vendor marker
  (stale/stopped endpoint) stay excluded on purpose.

Both validated via MCP validate_rule (0 errors) and dry-run with
test_analyze_rule: fire on the burst session (confidence 100 / 90),
silent on a timeout-shaped failure and on a succeeded session that
carried unrelated app_install_failed events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:40:17 +02:00
Oliver Kieselbach 040e411958 feat(classification,calibration): rule 5a completed-assumed, max-lifetime grace skip, drift-radar floors
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:

- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
  real-user desktop + zero app_install_failed reconciles to Succeeded
  "completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
  2.6% - the succeeded background, not the ~29% failure level - and the
  sampled desktop half had clean app records. No desktop or any failed app
  stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
  (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
  the stream: all five observed maxlife:r5_awaiting parks expired unhealed
  while live-agent parks upgraded 7/26. The maxlife ingest appends its
  trigger event to the read stream so the fact is never missed; late
  telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
  park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
  noise), per-path share regression now needs >=5 baseline hits (new or
  renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
  into the silence/classifier-verdict group sums on both sides of the
  2026-08-23 instrumentation cutover - dissolving the lift-124 silence
  artifact. Episodes below the fire floors clear themselves.

Backend only; the new path appears in the calibration matrix dynamically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:10:19 +02:00
Oliver Kieselbach 42c45cf567 feat(admin,backend): find a tenant by its notification or contact email
A delivery log names only the recipient, never the tenant the welcome mail was
for. The tenant console's search now matches the notification and contact
address as well as domain and tenant id; the quoted exact mode is unchanged, and
no other address is searchable (an admin UPN never receives platform mail and
would only widen the PII surface of the list).

The addresses live in PreviewWhitelist, not on the tenant config, so a new
GA/Reader-only GET /api/preview/notification-emails returns them as one map --
a single cross-partition scan instead of a point read per tenant. Both sides
join on the lowercased tenant id (address rows carry the JWT tid, a config
PartitionKey's casing is not guaranteed) and matching is case-insensitive,
because stored addresses keep the casing the user typed.

The map is best-effort in the portal: a failed read costs only the
search-by-address, never the tenant list. Addresses stay out of the list rows --
they are a search key, and remain visible in the tenant editor.

Match rules moved out of the component into tenantSearch.ts with unit pins, and
a catalog test pins that the plural route does not resolve to the per-tenant
entry one character away (which would demand a tenantId the route does not
carry, and hand a delegated caller a rescue path into the whole map).

Deploy: backend before web.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:41:05 +02:00
Oliver Kieselbach b31256c748 fix(activation): capture the welcome-mail address instead of redirecting past it
The activation page held the only address field in the product, and its redirect
effect fired on the first poll after auto-approve — roughly 60 seconds after
signup. Anyone who had not typed by then lost the field, no notification-email row
was ever written, and TrySendWelcomeEmailAsync deferred a mail that nothing would
send later. Nine of twenty-one August signups got a welcome mail; the last one
went out on the 21st.

It stayed hidden because every "not sent" branch logged at Information, and worker
application logs never reach Application Insights below Warning.

- Activation page holds the redirect until the address is saved or deliberately
  skipped, and switches to an "Activated" state while it waits. Visitors who
  arrive at an already-activated tenant are passed straight through as before.
- Recipient resolution falls back from the activation-page address to the tenant
  contact address. The signup admin UPN is deliberately not a fallback: those
  accounts often have no mailbox and the bounces cost sender reputation.
- WelcomeEmailSent / WelcomeEmailSkipped / WelcomeEmailFailed ops events make
  every outcome visible, dual-registered in OPS_EVENT_TYPES.

Three defects in the same path, found while fixing it:

- The sent-marker was consumed before the send and never released on refusal, so
  one provider hiccup suppressed a tenant's welcome mail permanently. It now
  records delivery, not an attempt.
- A missing provider key logged at Debug, hiding the one condition that stops
  every mail at once. Now Warning.
- The Global Admin resend endpoint reported success regardless of what the
  provider answered. It is the manual fallback for exactly this failure, so it now
  returns 502 and leaves the marker free when the message is refused.

The welcome mail is also no longer fire-and-forget: a discarded Task can be torn
down when the invocation ends, and its result is what the ops event reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:32:52 +02:00
Oliver Kieselbach 51f2dabc8c feat(web): explain the blank WiFi signal in the network timeline
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi
signal" stat that simply vanished — on a location-gated device that reads as a
product defect rather than the Windows 24H2 gate that caused it.

No agent change was needed: buildSegments already merges every wifi_signal_info
into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in
with the sample that was already being read.

- NetSegment.dataLimitedReason, filled from the sample.
- Segments that never got a sample of their own (a mid-session reconnect emits
  no wifi_signal_info) inherit it: the gate is a device-level setting and cannot
  flip during an enrollment. A segment WITH a real reading is never overwritten
  — pinned by a test.
- Tooltip: "Signal: unavailable — Location services off on this device" where
  the percentage would be.
- Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping
  the row.

network_state_change is deliberately left alone. That event has never carried
signalPercent or radioType — NetworkChangeDetector collects them into its
snapshot for change detection, but only before_/after_wifiSsid reaches the
payload — so there is no absent field there for a reason to explain, and the
SSID it does carry is unaffected by the gate. Adding it would ship a field
nothing reads.

Verified: web tsc + eslint (0 warnings) + vitest 893/893.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:06:49 +02:00
Oliver Kieselbach 0f9a77c848 feat(agent,web,mcp): surface the 24H2 location gate instead of only logging it
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since 058248d9 the SSID survives the gate via the WinRT tier —
but signal, radio and channel silently vanish with no explanation anywhere.

When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now
carries wifiDataLimitedReason = "location_services_off" and the event message
gains "— signal unavailable, Location services off", so the reason reads straight
off the timeline. The portal's WiFi card matches that literal and names the
Intune setting that restores the full payload.

The claim is evidence-only: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting
we did not observe. IsLocationGateDenied is the single predicate, pinned at its
boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as
the published agent/portal/MCP contract.

New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can
be sized before anyone changes a policy:
search_sessions(deviceProperties: {
  "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })

Backend is untouched — the event payload is opaque DataJson with no projection.
network_state_change and the network timeline deliberately do not carry the
reason yet; that is a second payload plus web change for no extra insight.

Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings)
+ vitest 889/889, MCP tsc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:58:31 +02:00
Oliver Kieselbach d89e3f0b48 fix(backend): cap vulnerability_report severity at Warning
A completed scan is a finding about the software inventory, not an
enrollment failure. Mapping the CVE risk onto Error/Critical made the
report read as a failed enrollment: the diagnosis page collected it as
failure evidence, MCP search lifted it on failure-intent queries, and the
timeline's Error filter filled up with clean sessions. critical/high now
map to Warning, everything else to Info; the CVE severity itself stays in
scan_summary and on the report panel's own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:54:58 +02:00
Oliver Kieselbach 058248d95c fix(agent): read WiFi SSID via ungated WinRT API — 24H2 location gate
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments
(build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01).
Not a parser or language problem: every event that exists carries a full SSID,
so the failure is binary at the reader level.

Since 24H2 Windows gates WlanQueryInterface with
wlan_intf_opcode_current_connection — the only opcode we use — behind
precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt
only fires for a process in the user's context outside System32, so the agent
(SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it
resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none
— while the interactive user on the same machine reads the SSID fine via the
per-user NonPackaged value. That is why it tracks the image's location setting,
not hardware, driver or language.

netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its
multi-language labels never covered the case they were built for; more labels
cannot fix it.

- New WinRtWifiSsidReader: Microsoft's documented replacement
  WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via
  the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same
  mechanism OobeStateReader already uses from the SYSTEM context.
- New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both
  collectors had duplicated.
- The step diagnostics added in 2026-07 were logged at Debug while the default
  level is Info, so they never reached a field log. The all-tiers-failed case is
  now a Warning that decodes rc=5 into the location-gate explanation.

Signal, PHY and channel have no ungated source and stay null on tier 2;
deriving a percentage from GetSignalBars would fabricate precision.

Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured
live (532/604), and the full WinRT chain resolves and returns the SSID under
net48.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:27:36 +02:00
Oliver Kieselbach aab0a0c52c feat(admin): make report ID one-click copyable in report details
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:45:41 +02:00
Oliver Kieselbach f22feb71ff feat(web): exclude events from the session timeline search
The timeline search box now tokenizes the query instead of treating it as a
single substring: terms are AND-ed and a leading minus hides its matches
(e.g. -app_install_progress), following the convention every other search box
uses. Quoting keeps a term literal, so an exit code like "-1" stays searchable.

The negation is decided at a token's first character, before a quote is
consumed, and terms match the same fields as the positive search (event type,
message, source) joined by a newline so a term can never match across a field
boundary. The event counter follows the search and an active exclusion is
named in a pill next to the severity filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 14:33:05 +02:00
Oliver Kieselbach c92fe7c2a0 commands: bump to 3061dca (cert tenant binding queries in backend-logs + session-debug) 2026-08-25 12:56:29 +02:00
Oliver Kieselbach b7cb75e442 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-25 12:50:12 +02:00
Oliver Kieselbach d6895f2c7e feat(security): enforce cert tenant binding — reject cross-tenant certificates
The Intune roots the chain is pinned to are shared by every Intune tenant, so
a valid certificate only ever proved "issued by Intune to someone". Tenant
scoping rested on the device serial, which is printed on the chassis. A
Mismatch now returns 403.

Switched on against measured data, not a hunch: 35,684 requests across 32
tenants since the shadow telemetry landed, every one a Match, with the
Warning channel independently verified to be carrying other traffic so the
zero could not be a telemetry artefact.

Only Mismatch blocks, and the rule lives in one function so "what rejects a
request" has a single answer. ExtensionMissing, Unparseable and
RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only
covers devices active in the window, so an older certificate or a
sovereign-cloud CA could still lack the extension, and rejecting on absence
would lock those devices out for something outside their control.
ExtensionMissing is the candidate for later tightening and has its own test
pinning the tolerance, so removing it must be a decision.

Evaluation fails open on its own exception — a defect on our side is not
evidence of a cross-tenant certificate. The 403 body does not echo which
tenant the certificate belongs to; that pair goes to the rejection log.

Device binding (stage 3) is untouched: still shadow, still Global-Admin-only.

Backend 4638/4638, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:45:21 +02:00
dependabot[bot] beac51720f build(deps): bump the web group (#135)
Bumps the web group in /src/Web/autopilot-monitor-web with 4 updates: [next](https://github.com/vercel/next.js), [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer), [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


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

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

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

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: vitest
  dependency-version: 4.1.11
  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-08-25 12:32:44 +02:00
dependabot[bot] 32268ba245 build(deps-dev): bump vitest (#134)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.11
  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-08-25 12:32:17 +02:00
Oliver Kieselbach c91d8d4501 fix(agent): complete enrollments with zero user-targeted apps instead of failing them
IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.

IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.

Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.

ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.

Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:27:42 +02:00
Oliver Kieselbach bf6850dedc perf(security): log DevPrep association shadow once per lookup, not per request
Same gate the cert-device-binding shadow got in 35a3ed0c, applied to its
sibling. Measured on the live enrollment: 263 identical shadow lines for one
device off a handful of Graph calls, because the cache answered nearly every
request and the log sat outside it.

DeviceAssociationResult.AsCacheHit() copies rather than marking the shared
cached instance, so the first real lookup is never mistaken for a cache hit
and dropped. Transient outcomes are not cached and keep logging every time.

Backend 4633/4633, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:18:57 +02:00
Oliver Kieselbach 35a3ed0cfb perf(security): log device binding once per Graph lookup, not per request
Measured on the first live enrollment: 136 agent requests produced exactly
ONE Graph call, because the 30-minute positive cache absorbed the rest. So
the check is not a throttling risk (the per-request Autopilot serial
validator made 25 calls in the same window) and the positive TTL needs no
change — raising it would only delay noticing a wiped or removed device.

What the 136 requests did cost was 136 log lines all saying the same thing.
Gate the shadow log on ServedFromCache so it emits one line per real lookup.
Cached repeats carry no new information: the reported age just counts up
mechanically from the same enrolledDateTime. Outcomes that are never cached
(Transient, NoDeviceIdInCert) keep logging every time, which is what an
anomaly warrants.

AsCacheHit() copies rather than marking the shared cached instance, so the
first real lookup is never mistaken for a cache hit and dropped.

Backend 4631/4631, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:18:37 +02:00
Oliver Kieselbach ea6a375c4b feat(security): cert-to-device binding, stage 3 GA-gated shadow
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.

IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.

Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.

The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.

Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).

Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:26:33 +02:00
Oliver Kieselbach fb79c7711c fix(security): carry cert tenant binding on the request row, not a dead log line
Post-deploy verification of 249e39bc found zero AgentCertTenantBinding rows
across 32k agent requests. Cause: the Match outcome was emitted with
LogInformation, and worker-side ILogger below Warning never reaches App
Insights (provider default rule is Warning+, host forwarding of worker logs
is off). The bulk outcome was therefore invisible, which is indistinguishable
from the code never running — the shadow telemetry had no denominator.

Stamp every outcome onto the request row instead (FunctionContext.Items ->
RequestTelemetryMiddleware -> CertTenantBinding dimension). That row already
exists per request and is unsampled, so the denominator costs no extra
telemetry. Non-Match outcomes keep their Warning trace with full context.

FromCache existed only to sample the dead Match line and is removed with it.

Backend 4606/4606, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:40:04 +02:00
Oliver Kieselbach 249e39bc68 feat(security): cert tenant binding, stage 1 shadow mode
A chain-valid Intune MDM certificate only proves "issued to some tenant" —
the pinned roots are shared by every Intune tenant. Tenant scoping rested
entirely on the device serial in the downstream validators, which is not a
secret. Bind the tenant stamped into the auth cert to the requested tenant.

The Entra tenant id sits in OID 1.2.840.113556.5.14 of the cert the agent
already presents for mTLS, so this needs no Graph call, no consent and no
agent release. Not 5.6 (Intune Account id, a different GUID) and not 5.4
(device id, stored as 16 bare bytes) — the decoder handles both encodings
and now lives once in Shared, used by agent and backend.

Stage 1 observes only: ObserveCertTenantBinding returns void and swallows
its own exceptions, so it cannot fail an otherwise valid request. Match is
logged only on cert-cache miss, keeping the denominator without per-request
volume. Grep marker for stage 2: CERT-TENANT-BINDING-SHADOW.

Backend 4604/4604, Agent V2.Core 2519/2519, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:03:05 +02:00
Oliver Kieselbach 6bd9139869 fix(agent): cert GUID parser accepts bare 16-byte extension values
Intune's MDM device cert stores the DeviceId in OID 1.2.840.113556.5.4 as a
bare 16-byte GUID with no ASN.1 wrapper, so the strict OCTET-STRING parser
bailed on the tag check and returned null. Verified against a live Intune
device cert: 5.4 is 16 raw bytes, while 5.6 / 5.11 / 5.14 carry the usual
04 10 wrapper.

- ParseGuidFromAsn1OctetString -> ParseGuidFromExtension (the old name no
  longer describes what it parses); both encodings now handled, with the
  shared LE-GUID construction pulled into ToGuid(). The forms cannot
  collide: a wrapped 16-byte GUID is always >= 18 bytes.
- Document the Intune MDM cert OID arc on the class: 5.4 DeviceId,
  5.6 Intune AccountId (NOT the Entra tenant), 5.14 Entra TenantId,
  5.11 undocumented GUID, 5.15/.16/.17/.18/.19/.23 INTEGER enums.
- 13 tests pinning both encodings plus the malformed-input rejections,
  including a bare GUID whose first byte is the 0x04 OCTET STRING tag.

No behaviour change for the MS-Organization-Access probe the agent uses;
TryGetTenantIdFromCert is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:41:00 +02:00
Oliver Kieselbach 73373a2c9e feat(agent): compiled-in fallback rule catalogs for the no-config default
The built-in default config shipped empty ImeLogPatterns/GatherRules lists, so a
session that got neither a live fetch nor a cached config ran with the entire IME
tracking pipeline dead — no app installs, no script results, no health-script
compliance, no IME ESP-phase signals — although the agent binary carries all 37
action handlers. The scalar knobs never drift (shared AgentConfigResponse property
initializers are also the backend's fallbacks), but the catalogs cannot come from
initializers.

BuiltInConfigCatalog embeds the SAME CI-combined rules/dist artifacts the backend
embeds and serves (ime-log-patterns.json, gather-rules.json) and feeds the enabled
entries into CreateDefaultConfig. Single source by construction: each agent release
snapshots the catalog state of its own build, so the offline fallback can never
drift further than the running agent version itself. A live fetch (startup,
post-registration recovery, rotate_config) always wins. Fail-soft: a load error
degrades to the previous empty-list behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:14:30 +02:00
Oliver Kieselbach 6d607c9045 fix(agent): network-link gate before initial config fetch + post-registration recovery
Root cause (tenant aebdce78 audit 2026-08-24, session ba828038): on boot-time
relaunches the agent starts before the Wi-Fi link is up. The Phase 4 config fetch
had no network gate (98f8114d only guarded Phase 6 registration), burned its
10/30/60s retry budget into a dead link and stranded the whole session on built-in
defaults (ConfigVersion=0) — up to ~50% of that tenant's sessions, with no
self-heal short of a manual rotate_config action. Not a version regression: rates
varied wildly per day within the same agent build; the failed requests never
reached the backend.

Two complementary layers:
- RemoteConfigService gates the initial fetch on the shared NetworkLinkWait poll
  (60s cap — longer than registration's 15s because the fetch runs first), so the
  retry budget starts on a live link. rotate_config stays ungated (latency-bound).
- Phase 6.5: after registration proves the backend reachable, TryRecoverConfigAsync
  refetches once and AgentRuntimeConfig.TryRecoverAfterRegistration re-applies the
  live config (same merge/integrity pipeline as Phase 4). LastFetch* keeps the
  original failure record; remote_config_fetch_failed reports it with a recovered
  flag at Info severity, agent_started flags remoteConfigRecovered.

Deliberately not re-run in recovery: kill-signal check (telemetry-channel control
signals cover post-registration kills) and endpoint migration (next start).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 11:53:04 +02:00
Oliver Kieselbach 3af94d9dcd infra(telemetry): DCR also drops host-emitted successful SignalR dependencies
Live-verified after 0d5e2572 deploy: the worker filter removes every 404/412/409
and worker SignalR row, but the SignalR output-binding calls are emitted by the
host process (SDK rdddsc) and only reachable at ingestion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:15:41 +02:00
Oliver Kieselbach 0d5e2572aa perf(telemetry): one canonical request record; drop expected storage outcomes and SignalR success deps
- RequestTelemetryMiddleware: SamplingPercentage=100 per item (worker copy was
  adaptively sampled, ItemCount 2-5 on weekdays, despite host.json excludedTypes)
- StorageDependencyFilterProcessor: also drop 404/412/409 storage outcomes
  (InProc + HTTP shape) and successful *.service.signalr.net REST calls
- infra/appinsights-workspace-transforms.bicep: DCR dropping the host HTTP
  request duplicate at ingestion (timer/queue rows kept)
- docs/backend/telemetry-ingest-shaping.md; skills updated

Measured baseline 7d: ~1.3 GB/week removed, requests now 100% with tenant context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:31:32 +02:00
Oliver Kieselbach 793e907e60 fix(cveindex): merge findings per CVE before upsert; bounded parallelism
One CVE commonly appears under many findings of the same report (e.g. one
RD CVE across 14 installed RD components). Each occurrence produced its own
concurrent upsert on the same PK/RK, so the stored SoftwareName/OverallRisk
was race-dependent. Collapse to one entity per CVE (highest CVSS wins,
IsKev OR-ed, OverallRisk max) and write with Parallel.ForEachAsync(8) and
per-entity fail-soft logging instead of Task.WhenAll + one outer catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:52:02 +02:00
Oliver Kieselbach 8e8bb66371 test(agent): thread-safe FakeSignalIngressSink (parked-tripwire CI flake)
Timer-thread Post() appended to a bare List while the test polled it in a
SpinWait foreach -> 'Collection was modified' (run 32644425162, no assert
text). Posted now returns a locked snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 16:28:58 +02:00
Oliver Kieselbach 7e1389cf32 fix(ratelimit): keep history and lock in one cache entry; drop leaking per-key lock map
Both sliding-window limiters re-Set the cache entry without its eviction callback.
The replaced entry fired the callback asynchronously, removing the per-key lock while
a new one was already handed out, so two threads could mutate the same List under
different locks; the replacement entry never cleaned its lock, leaking one object per
key for the process lifetime.

The history now lives in a RequestBucket together with its own lock: same list ⇒ same
lock, no callback, no dictionary. Bucket creation is serialized (GetOrCreate is not
atomic) behind a lock-free TryGetValue fast path. The redundant re-Set is gone — the
lookup already refreshes the sliding expiration.

Concurrency tests (16 threads × 500 requests, one key) fail 3/3 on the old code and
pass on the new one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 16:26:44 +02:00
Oliver Kieselbach 3a89319472 feat(telemetry): startup duration metrics for backend and MCP
StartupTelemetryService emits BackendStartupMs (process start ->
ApplicationStarted) and BackendTableInitMs as App Insights custom
metrics with version/commit/tableInitFullPass/instance dimensions;
TableStorageService exposes LastInitialization. MCP server writes one
{"type":"boot","readyMs"} stderr line per start next to the
tool_call lines so cold-start time is queryable as a trend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 16:04:31 +02:00
Oliver Kieselbach b37e6f975c perf(startup): table schema sentinel gates CreateTableIfNotExists pass
Cold start point-reads a SHA-256 over TableNames.All stored in
AdminConfiguration/SchemaSentinel/tables instead of issuing 66
CreateTableIfNotExists calls. Missing/stale sentinel runs the full
idempotent pass once and rewrites it. SessionsIndex startup backfill
only after a full pass, behind a conditional-insert claim (one instance
under scale-out); IsSessionIndexEmptyAsync no longer treats errors as
empty. Daily maintenance runs IStorageInitializer.EnsureAllAsync to
repair out-of-band table deletions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 15:33:45 +02:00
Oliver Kieselbach cacbd24667 feat(mcp): get_verdict_calibration response shaping (explicit nulls, minSharePct/top, omitted report)
Drop per-row window7/baseline28.sessions (duplicated trend denominators), make
reEnrollRatePct/lift always present with null = withheld, add minSharePct/top
tail trimming that never drops override-carrying rows and reports what was
omitted. Backend wire shape and admin page untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:51:17 +02:00
Oliver Kieselbach a157d98a40 fix(radar): verdict share regression only for backend-decided paths
First production pass alerted on agent:whiteglove_pending (two tenants in a
WhiteGlove rollout week) and agent:complete_soft (a Continue-Anyway tenant) —
customer workflow mix, not classifier drift. agent:* and register:* are now
ineligible for share_regression (still in the matrix and the silence group);
episodes opened by the broader gate re-arm on the next pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:32:00 +02:00
Oliver Kieselbach 7fa50d09c1 perf(maintenance): one projected 35d Sessions scan shared by all rolling sweeps
The cross-tenant StartedAt-only date-range query is a full-table drain in
Table Storage; time attribution, device journeys, verdict calibration and the
calibration radar's tenant discovery each ran their own — four identical reads
per 2h tick. RunAllAsync / RunManualAsync now load the window once through
GetMaintenanceWindowSessionsAsync (MaintenanceSweepSessionProjection: the union
of the sweeps' columns, drops FailureSnapshotJson/geo/OS/diagnostics) and the
sweeps slice it by StartedAt. MaintenanceSweepProjectionEquivalenceTests pins
that every consumed field survives the projection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:19:41 +02:00
Oliver Kieselbach 94afc02e62 docs(okf): verdict calibration concept (VerdictPath, prior verdicts, aggregate, radar)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:40:34 +02:00
Oliver Kieselbach 5e4c5e80be feat: verdict calibration drift radar (share / silence / evidence-gap) with ops events
Applies the rule-regression statistics (7d vs 28d, lift >=2, Wilson-separated,
small-n gates) to the verdict-calibration rows: per-path share regression,
sweep+maxlife silence-share regression (agent liveness signal) and an
absolute r6 evidence-gap gate. Episodes live in the notification tracker
(verdictcalibration|kind|path|status), fire one VerdictCalibrationDrift ops
event (operator-only, no tenant bell), re-arm under 1.5x / 15%, and surface as
alerts[] + a Drift pill in the calibration matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:39:06 +02:00
Oliver Kieselbach b535973531 feat: verdict calibration aggregate, GA endpoint, MCP tool and operator matrix
Daily (tenant x StartedAt date) buckets per verdict path: count, derived
attribution, 7-day re-enrollment proxy (eligible/re-enrolled), and the
correction stream attributed to the PRIOR path (admin / late completion /
other). Written by the 2h maintenance sweep after the device-journey pass,
180d retention, regenerable. Read via GET global/metrics/verdict-calibration
(GlobalReadOrAdmin, 7d-vs-28d share trend, rates withheld below n=20),
the get_verdict_calibration MCP tool, and the admin metrics section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:22:17 +02:00
Oliver Kieselbach 7e0c578a59 feat(backend): VerdictPath + PriorStatus/PriorVerdictPath on every session status write
Every status writer now declares the code path that produced the status
(VerdictPaths vocabulary, stamped for all statuses — unlike FailureSource
which is Failed-only), the timeout classifier returns its rule id, and a
write that overrides a prior verdict preserves it as PriorStatus/
PriorVerdictPath (admin mark, late completion, retro). Pre-instrumentation
rows derive a path read-side (VerdictPathDerivation, legacy:* when
ambiguous). Foundation for the verdict calibration aggregate + radar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:06:37 +02:00
Oliver Kieselbach 1257e0d195 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-23 09:57:39 +02:00
Oliver Kieselbach 98f8114d90 fix(classification): self-deploying profiles reconcile to Succeeded after Device Setup, never AwaitingUser
Kiosk tenant audit 2026-08-23: 836 of ~970 self-deploying sessions in one week
were parked AwaitingUser ("Account Setup 0/5") and graduated to Incomplete 51h
later although the devices were finished — the profile has no user phase, so the
classifier's rule 5 was asking for evidence that can never arrive.

- EnrollmentTimeoutClassifier: new rule 1c IsSelfDeployingProvisioned (profile flag +
  Device Setup all-succeeded + no explicit failure) => Succeeded reconcile with an
  honest reason; isSelfDeployingProfile passed by every caller (sweep stage 1+2,
  max-lifetime verdict, late-telemetry heal, legacy retro mode).
- MaintenanceService stage 1: TryReconcileSelfDeployingAsync gate before the Stalled
  marker (mirrors the WhiteGlove Part-2 gate), audit counter SessionsSelfDeployingReconciled.
- LegacyReclassificationService: new admin mode self_deploying_silent (heal-only,
  dry-run by default) + GetSelfDeployingSilentSessionsAsync for the existing backlog.
- Agent SessionRegistrationHelper: wait up to 15s for a network link and retry 6x
  (~62s) instead of 5x (~30s) so the BootTrigger relaunch on Wi-Fi no longer exits 7
  before the link is back (the mechanism that left these sessions silent).
- Tests: 6 classifier cases (gate, within/past grace, user-driven guard, failure,
  pre-Device-Setup), 2 registration tests; docs/backend/silent-session-classification.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 09:55:06 +02:00
Oliver Kieselbach 7b3230d507 fix(web): mobile wrapping for App Details header, Software tables and SLA banner (#133) 2026-08-23 05:19:20 +02:00
Oliver Kieselbach 87b7a20223 fix(web): diagnostics path rows wrap the full path on narrow screens
Below `sm` the path owns the first line(s) and wraps in full (flex-wrap,
basis-full, break-all) with badges and controls flowing onto the next line —
a path truncated to "C:\…" is useless on a phone and a tooltip cannot be
hovered there. From `sm` up the row stays a single truncated line. The
"N file types" pill is shown on every width again now that the badge line
has room.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:30:10 +02:00
Oliver Kieselbach 4c19be1de4 feat(web): drop Experimental/Pre-Release badges from Software Inventory & Vulnerability Analyzer
Settings section loses both pills; the vulnerability report footer keeps the
best-effort CVE matching note without the Experimental prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:26:55 +02:00
Oliver Kieselbach 93de130cd1 feat(diagnostics): built-in collection catalog visible in the portal, RealmJoin + Device Preparation sections gated
The diagnostics package's built-in collection is now ONE catalog in Shared
(DiagnosticsBuiltInSections.All): the agent iterates it when building the ZIP and
the backend serves the same list to the portal (GET /api/diagnostics/paths,
MemberRead), so what administrators see is exactly what the agent collects.

Agent
- New conditional sections: RealmJoin client/package/Chocolatey/per-user logs
  behind the tenant's RealmJoin Watcher toggle (mirrored onto
  AgentConfiguration.EnableRealmJoinWatcher by RemoteConfigMerger) and the Device
  Preparation bootstrapper event log behind the deterministic WDP marker (probe
  once per build, fail toward Classic). ZIP layout mirrors the disk
  (RealmJoinLogs/Windows|Choco|User). Every skip is a package-manifest line.
- Built-ins bypass DiagnosticsPathGuards by design (reviewed code); caps,
  reparse-point skip and evtx export still apply. No ConfigVersion bump.

Backend
- GET diagnostics/paths (MemberRead, JWT-scoped) with the catalog + global paths.
- MergeDiagnosticsLogPaths: global ∪ tenant dedup (trimmed, case-insensitive) —
  duplicates used to produce duplicate ZIP entry names.

Web
- Collapsed read-only built-in list (RealmJoin on/off in the tenant header,
  neutral in the Global-Admin view), one-line DiagnosticsPathRow for every entry,
  add-row above the lists; tenant context no longer fetches /api/global/config.

Tests: agent RealmJoin on/off, no user session, bootstrapper evtx, probe failure,
merger mirror; backend catalog invariants, payload shape, dedup, policy; web
builtInSectionDisplay. Docs: docs/agent/diagnostics-package.md (+index/log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:09:50 +02:00
Oliver Kieselbach b8b95c7e76 refactor(web): move RealmJoin Watcher and Keep Awake from Agent Analyzers to Agent Settings
Both toggles gate collector hosts / device behavior, not IAgentAnalyzers, and
tenants looked for them under Agent Settings. RealmJoin Watcher now sits in the
Agent Collectors card, Keep Awake During User-ESP in Agent Parameters; OOBE
console detection stays with the security analyzers. Section ownership in
SECTION_FIELD_MAP and the per-section reset handlers follow, the Agent Settings
Save/Reset bar moves below both cards it saves, and the Analyzers sidebar
description names all four checks. Wire contract (AnalyzerConfiguration) and
agent are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:06:39 +02:00
Oliver Kieselbach e57e081cec docs(web): Discord in notification-provider copy on about, privacy, settings and rule card
Privacy page last-updated bumped to 23 August 2026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:47:52 +02:00
Oliver Kieselbach 3a60186824 revert(email): drop the gradient slant row and corner cell — solid fallbacks made non-gradient clients worse
Back to the 838da4cc header: one facet gradient, three-row brand mark, 113px
band, clip-path edge for clients that support it. Clients without gradient
support get a flat band instead of band-coloured blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:30:40 +02:00
Oliver Kieselbach c35595503f feat(email): header slant and light corner facet without clip-path — gradients Outlook can draw
Row 1 keeps the single facet gradient and adds a top-right cell with its own
dark/light split (the corner facet); row 2 draws the slanted bottom edge as a
green/white gradient in three band-coloured cells. Pixel-measured against the
reference render: band boundaries identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:22:44 +02:00
Oliver Kieselbach 838da4cc8a fix(email): single Outlook-safe facet gradient, no bgcolor inside the brand mark
Outlook web/iOS render exactly one linear-gradient with hex colours and % stops
(multiple layers / rgba stops are dropped), and dark mode inverts cell
backgrounds but not border colours — the white cell in the mark became a black
bar. Facets are now one gradient (top-right light corner dropped), the mark's
middle row is a white-bordered div.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:11:17 +02:00
Oliver Kieselbach a9937e0f5d fix(email): brand mark as three non-overlapping rows — Outlook ignores negative margins
Triangle / white cell + triangle / triangle, no overlays, no transparent td
borders; matches the reference mark in a side-by-side zoom render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:02:14 +02:00
Oliver Kieselbach 1dc2cc9a54 fix(email): header rebuilt 1:1 from the approved banner — faceted gradients and four-edge brand mark
Verified side-by-side against the reference render (scratch header-render.html):
three layered half-plane gradients reproduce the light/dark facets, the mark is
a CSS trapezoid plus two green wedges for the slanted right edge and the
descending bottom edge. Still no images in the mail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:44:26 +02:00
Oliver Kieselbach dd7ae299a8 fix(email): header restored to the approved rendering (e1de5a89 geometry)
112deg stripe gradient, 13/20/2 border-drawn mark, clip-path edge — the version
the user signed off on; later 'improvements' drifted from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:23:22 +02:00
Oliver Kieselbach d96d10ccec fix(email): header matches the reference banner — mark 12/21/0 trapezoid, stripes thin/wide/thin in the proven 8-stop gradient form
Verified against the original SVG mark in a Chromium render; the gradient keeps
the exact stop count that rendered in Outlook earlier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:05:12 +02:00
Oliver Kieselbach 457fff65f5 fix(email): brand mark back to the div border trick (Outlook paints transparent td borders white)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:56:09 +02:00
Oliver Kieselbach dc45694611 docs(okf): email template overrides and image-free mail header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:41 +02:00
Oliver Kieselbach 7569061983 feat(admin): editable email templates — preview, test send and HTML override in Settings
- EmailTemplateService: override-or-built-in resolution per send (PreviewConfig
  table, partition EmailTemplates, 5-min cache, {{domainName}} placeholder,
  30k limit); send path fail-soft, admin read path strict
- GA endpoints global/email-templates/{kind} (GET/PUT/DELETE) and /test, which
  mails the effective template or an unsaved draft to the caller's tenant
  contact address
- portal: Settings → Email Templates with preview frame, test send, HTML editor
- mail header: brand mark drawn with CSS borders, no images left in the mails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:10 +02:00
Oliver Kieselbach 0f5e36db94 fix(email): exact brand mark as PNG again, stripes direction and widths match the reference
The CSS border trapezoid did not reproduce the mark's shape across clients; the
mark is brand identity, so it is the one image in the mail (empty alt). Stripes
now run top-left to bottom-right, thin/wide/thin as in the reference banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:40:15 +02:00
Oliver Kieselbach e1de5a8971 feat(email): image-free header — CSS stripes, slanted edge and border-drawn brand mark
Progressive enhancement: solid bgcolor band + text wordmark everywhere; the
brand mark is a CSS border trapezoid; diagonal stripes (linear-gradient) and the
slanted bottom edge (clip-path) render where supported and degrade to a flat
band elsewhere. No remote images left in either mail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:22:26 +02:00
Oliver Kieselbach a2d93e884c fix(email): native HTML header that survives blocked images
Solid bgcolor band with the wordmark as real text; only the 36px brand mark is
an image (empty alt). Replaces the full-width header PNG, which collapsed to a
bare green bar on clients that block remote images (iOS Mail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:15:45 +02:00
Oliver Kieselbach 425229deee feat(email): branded header image, sign-in link, drop preview banner in welcome/farewell mails
- header is a hosted PNG (www/email/header.png, rendered from the brand mark +
  wordmark) on a solid bgcolor band; mail clients drop CSS gradients, which left
  the old header white
- welcome mail links "sign in" to the portal, active-development banner removed
- em dashes replaced with en dashes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:55:53 +02:00
Oliver Kieselbach 8675e7ee06 feat(backend): provider-neutral EmailService on Mailchimp Transactional (Mandrill), replaces Resend
- EmailService + IEmailService: Mandrill messages/send via typed HttpClient with the
  Notification resilience policy; provider confined to one transport method and the
  Email:* settings (ApiKey, Endpoint, FromAddress, FromName). Resend NuGet dropped.
- open/click tracking explicitly off; sent/queued/scheduled = ok, rejected/invalid = warn
- callers (TenantApprovalService, PreviewWhitelistFunction) depend on IEmailService
- EmailServiceTests pin wire format, result semantics and fail-soft contract
- privacy page + OKF welcome-mail doc updated for the new sub-processor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:26:32 +02:00
Oliver Kieselbach e857e99436 fix(web): list Generic JSON provider last in channel provider dropdown
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:54:34 +02:00
Oliver Kieselbach 6435d5f290 feat(notifications): Discord webhook provider + HMAC signing for generic webhooks
Discord (provider 30) renders alerts as a single embed with Discord's
field/length caps; actions become markdown links since webhooks cannot
post buttons. Generic JSON channels gain an optional per-channel signing
secret: requests carry X-AutopilotMonitor-Timestamp and
X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"),
computed over the exact posted JSON with same-named custom headers
stripped first. Secret is redacted for readers and restored by channel
id on save; validation enforces 16-128 printable ASCII characters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:09:12 +02:00
Oliver Kieselbach 445ce4e75b feat(backend): 30-day retention grace period on Pro downgrade
Retention was the only enforcement point that destroyed state on a
Pro -> Community downgrade: the 12-hourly sweep clamps to the Community
cap at read time and would immediately start hard-deleting the
90-365-day band (trial expiry identical, silently).

New policy: for 30 days after losing Pro
(FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap
keeps applying. Anchor model: ProDowngradedUtc (backend-only field,
patch-denied + revert-protected) stamped/cleared by the plan endpoint's
new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers
explicitly ended trials; a planTier downgrade under an active trial
deliberately does not stamp); trial expiry anchors read-time via
TrialExpiresUtc. days<=0 (infinite) stays untouched.

Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService
dep) plus TenantRetentionGraceExpiring/Ended from the daily
TrialExpirySweep with the same stateless window mechanics, emitted only
when stored retention exceeds the Community cap; all dual-registered in
the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam
instead of inline DateTime.UtcNow. OKF doc
backend/plan-downgrade-retention-grace.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:00:53 +02:00
Oliver Kieselbach 4e54ffe4b4 feat(web): shared plan cards, public /plans and /buy pages
Community/Pro comparison cards extracted into a shared module
(components/plans: planData.ts as single copy source, PlanCards.tsx with
marketing/portal surfaces) so the portal Plan section and the new public
/plans page can never drift. Pro price is one constant (null renders
"Pricing - announced soon").

New public pages following the /help pattern: /plans (free-first
messaging, nav item "Plans") and /buy (direct purchase + Microsoft
Marketplace as coming-soon channels). Landing nav, footer, FinalCta and
get-started now link to /plans instead of the docs plans page; portal
Plan section links to /buy via absolute www URL. Purple utilities
neutralized inside .dark .landing-v2; sitemap/robots/lastmod cover
/plans, /buy and the previously missing /help.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:46:44 +02:00
Oliver Kieselbach 65d45a616e feat(web): Community Edition tag under navbar wordmark
Community tenants see a small "Community Edition" line under the brand
wordmark as a standing reminder (and nudge toward upgrade options); Pro
tenants (incl. trial) keep clean chrome. Label renders only after the
feature-flags fetch confirms the edition - no flash for Pro while loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:38:19 +02:00
Oliver Kieselbach 4aa1f07fbb feat(web): green welcome accent + session-dismissable dev banner with telemetry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:17:53 +02:00
Oliver Kieselbach ede616444a fix(web): honest skip/uninstall/pending semantics in Download/Install Progress
IME reports skips and uninstall enforcements through the same completed
events, so both panels counted them as successful installs/downloads and
their pills contradicted the app_tracking_summary headers (session 502274b4:
"2 of 8 installed" vs "6 completed").

- Install fold: app_install_completed with state Skipped -> Skipped;
  successful uninstall intent -> new terminal state Uninstalled with its own
  pill and neutral row badge; completed now counts only real installs.
- Download fold: rows are classified by observed byte activity, never by
  intent (Win32 uninstalls re-download the full package and stay visible
  with an Uninstall badge); 0-byte rows sit behind a "no download" toggle.
- Both panels render tracker-known pending apps (summary pendingNames)
  behind a "pending" toggle, so every "X of Y" header fully adds up.
- Headers wrap on narrow screens (flex-wrap + gap instead of space-x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:42:23 +02:00
Oliver Kieselbach 61a359ee54 feat(web): public /help page for support channels
Adds www.autopilotmonitor.com/help as a mail-linkable page describing how
to get help: Report Session in the portal for session-specific problems,
GitHub Issues for everything else, LinkedIn for private contact, plus
self-service links (docs, FAQ, announcements, changelog) and the private
security-advisory path. Registered as public path in hostRouting (guard
test pinned) and linked from the site footer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:12:09 +02:00
Oliver Kieselbach babc89c776 fix(backend): park silent WhiteGlove Part-2 sessions as AwaitingUser instead of Stalled
fairstone.ca analysis 2026-08-21: after the reseal-reboot technicians routinely
power devices off at the logon screen to box them. Both silence paths (2h
maintenance sweep, agent stall probe) then labeled the expected parking state
"Stalled - Agent silent", and stage 2 later reconciled it to Succeeded on the
Part-1 whiteglove_complete with a reason claiming Account Setup completed.

- EnrollmentTimeoutClassifier: shared pure gate IsWhiteGloveAwaitingUser
  (pre-provisioned + ResumedAt + Device Setup provisioned + zero user evidence
  since resume) with one canonical customer-facing reason; new rollup field
  HasEnrollmentComplete separates real completions from Part-1 whiteglove_complete
- ClassifyTimedOutSession: gated sessions are AwaitingUser within grace and
  reconcile past grace with an honest WhiteGlove reason; on resumed sessions
  whiteglove_complete no longer counts as terminal-complete evidence (rule 2),
  so partial user progress falls through to the honest AwaitingUser/Incomplete rules
- MaintenanceService stage 1 + session_stalled ingest mapping apply the same
  gate (fail-soft to Stalled); sweep audit now carries SessionsAwaitingUser
- Callsites (stage 2, max-lifetime verdict, late-telemetry reconcile, legacy
  reclassification) pass IsPreProvisioned/ResumedAt through

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:30:29 +02:00
Oliver Kieselbach a2f448cad0 fix(web): day-scale duration formatting in network timeline
fmtDuration rolls hours into days above 24h ("768d 8h" instead of
"18440h 58m") - minutes are noise at day scale. Surfaced by the first
fleet session with a CMOS clock years off (0bd4af1c, 2024 -> 2026 step);
also keeps the slanted clock-marker label inside its headroom. Test
pinned on the real fleet delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:05:04 +02:00
Oliver Kieselbach f15e115dea fix(sessions): backfilled system-timeline events no longer anchor StartedAt/duration
Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from
the Windows event log with its original timestamp, ~14min before the first
real activity) became the partition's earliest row and re-anchored the
session's StartedAt, inflating the duration toward pre-enrollment OOBE idle
time. With the watcher's 24h backfill lookback, the 2h backward-shift guard
only caps this effect, it does not prevent it.

SystemTimelineWatcher rows (clock steps, sleep episodes) are environment
observation, not enrollment activity, and are now excluded from all three
anchor derivations: the Events-table earliest-row probe (new
IsSessionAnchorEligible, probe scans past ineligible rows), the ingest
batch's EarliestEventTimestamp fold, and the web's client-side
enrollment-duration derivation. The events themselves stay on the timeline
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:18:57 +02:00
Oliver Kieselbach 977c5fb447 fix(web): headroom + symmetric insets for network-timeline marker labels
A left-edge "Clock -3h 0m" label collided with the HTML legend above the
band (first live fleet session d0c5b672). Marker-label headroom grows to
48 units so long labels stay inside the SVG, and the drawing content is
now inset 64 units on BOTH sides - the left inset mirrors the existing
right one, giving edge labels room and a calmer, symmetric frame.

Verified against the live session carrying the first system_clock_changed
event (backfilled step -> marker only, no rebase, as designed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:14:29 +02:00
Oliver Kieselbach 27fdd19568 feat(agent): optional Delivery Optimization GroupId from network fingerprint
EnableDoGroupIdAutoSet (ConfigVersion 40, default off): the agent seeds the
DOGroupId policy value with a deterministic GUID derived from the default
gateway's IPv4 + SendARP MAC, RealmJoin-compatible byte layout so mixed
fleets peer together. Foreign DOGroupId/DOGroupIdSource policies always win
(ownership tracked via StartupEventGate fingerprint); no random-GUID
fallback. New do_group_id_auto_set event, portal toggle, admin report row,
manifest/catalog sync, OKF doc. Gateway heuristic deduplicated into
NetworkInterfaceLocator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:09:45 +02:00
Oliver Kieselbach 72f7adcb3d feat(web): clock-era axis + sleep segments in network timeline
Consume the two new agent events:

- system_clock_changed: live-observed steps (timeDeltaMs authoritative,
  >=30s; sub-30s NTP nudges ignored) split the stream into clock eras and
  rebase every later timestamp by the cumulative delta, so the axis shows
  true elapsed time. Backfilled steps (pre-agent, replayed from the event
  log) are marked but never rebased. Each step gets a slanted "Clock +1h"
  lifecycle marker with old->new time and reason in the tooltip.
- system_sleep_episode: episodes (emitted retroactively at wake) carve an
  "Asleep" segment out of the active network instead of attributing the
  quiet period to it; tooltip carries kind (Modern Standby/sleep), wake
  source and AC/battery. Episode boundaries get the same era correction
  as the event that carried them.

Stats row gains "Asleep total" and "Clock changes"; the event list
includes both new types (now titled "Network & system events").

Layout: drawing content stops 64 units short of the right viewBox edge
(CONTENT_W), so right-edge lifecycle labels slant up-right like all
others instead of flipping left.

7 new vitest cases pin era rebasing (live/backfilled/nudge), sleep
overlay incl. era-corrected boundaries, and hotspot confidence.

Live verification against fleet data pending - the emitting agent was
released minutes ago; no session carries the events yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:57:19 +02:00
Oliver Kieselbach 06cf1c4c12 feat(agent): system timeline watcher — clock steps + sleep episodes as ground truth
Two enrollment blind spots become timeline ground truth from the System event
log via one EventLogWatcher (WindowsUpdateTracker pattern, 24h backfill with
RecordId watermark recovering pre-agent records):

- system_clock_changed (Kernel-General 1): oldTime/newTime/signed timeDeltaMs,
  reason and the setting process. 2s floor suppresses w32time 1ms micro-slews;
  steps >= 5min are Warning + immediate upload. Payload instants are the
  authoritative source (event timestamps can be clamped server-side).
- system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate,
  Kernel-Power 507 Modern Standby; 506 deliberately unconsumed): kind,
  enteredAt/exitedAt, durationSeconds, wake source / AC state. 60s real-sleep
  floor filters Modern Standby screen-off scenarios.

ConfigVersion 39 (Collectors.EnableSystemTimelineWatcher +
SystemEventBackfillLookbackMinutes). Both types are non-activity on agent and
backend (idle/stall clocks unaffected; a sleeping device stays Stalled).

Consumers: TimeJumpBadge gains the ground-truth cause "clock-set"
(classifyTimeJump matches backward display steps against recorded clock sets);
EventTimeline badge blocks for both events plus the previously bare
power_state_change; SessionInfoCard keeps the wall-clock duration and adds a
standby note; TimeAttributionCalculator builds SleepSpans (RebootSpan clone,
AttributionVersion 3) persisted as SleepSeconds/SleepSpansJson and rendered in
TimeAttributionLane; ANALYZE-DEV-012 (info, single) fires once per session for
episodes >= 5min - deliberately the only new rule, clock findings stay with
DEV-008 to avoid duplicate-finding flood.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:41:56 +02:00
Oliver Kieselbach e612c69178 feat(web): feature-form default for the grant command + docs link
The generated PowerShell command now uses the readable -Features form
(first catalog feature as the example) instead of raw permission strings;
a note below lists all available values incl. the All meta-feature and
how to fall back to -Permissions via Copy permissions. The section links
to the Optional Graph Permissions customer docs (via DOCS_URL registry),
and the "What this script does" bullet reflects the Cloud Shell
ambient-identity sign-in. Script help no longer claims the admin UI
generates the -Permissions form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:27:51 +02:00
Oliver Kieselbach 829a841d9b feat(web): slanted inline labels for network-timeline lifecycle markers
The gray lifecycle dots (Reboot / Desktop / Completed) were hard to hit
and mute without hovering. Their label is now written directly at the dot,
slanted up-right (flipping up-left near the right edge so it stays inside
the drawing), with the band shifted down to make headroom. The whole
marker group (line, dot, label) shares an enlarged invisible hit target,
and a label is suppressed when its neighbor sits too close - the tooltip
still carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:09:03 +02:00
Oliver Kieselbach 10eedded13 feat(web): confidence tier for network-timeline hotspot badge
An SSID matching the typical iPhone hotspot name (".. iPhone") is strong
evidence on its own — such networks now get a plain "Hotspot" badge, while
subnet-only evidence keeps the hedged "Hotspot?". Tooltip and footnote
explain the distinction; detection also fires on iPhone-named SSIDs
outside the Apple hotspot subnet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:00:56 +02:00
Oliver Kieselbach a9f6ccd86d feat(web): power + network status chips on the progress portal
Taskbar-style chips in the progress page status header showing the
device's last-reported power state (AC/battery with percent, low-battery
warning) and network state (WiFi with SSID tooltip + signal tiers,
Ethernet, offline). Derived purely client-side by folding the event
stream the portal already downloads (power_state_check/change,
network_interface_info, network_state_change, wifi_signal_info) - no
backend, API, or auth change. Chips render only while InProgress and
disappear gracefully for sessions from agents without these events;
devices without a battery show no power chip (Windows-tray parity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:56:04 +02:00
Oliver Kieselbach 7ba67df1a1 Agent instructions 2026-08-21 18:39:35 +02:00
Oliver Kieselbach e214e8dc1a feat(scripts): -Features All meta-value grants every optional permission at once
All is a script-side convenience that expands to every GraphFeatureCatalog
feature (works with -Revoke too). The catalog sync test now pins All in the
ValidateSet while keeping it out of the catalog comparison, and the admin UI
mentions the shorthand next to the generated grant command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:32:48 +02:00
Oliver Kieselbach f8d0ccd2f5 feat(web): per-session network timeline page + device-details deep link
New /sessions/network-timeline?id=&tenantId= page visualizing every network
the device was on during enrollment: one colored segment per network
(Ethernet / WiFi SSIDs, fixed-order validated palette), hatched offline
gaps, ESP phase context lane, lifecycle + connectivity-check markers with
hover/tap tooltips, stats row and the raw network event list.

Segment model derives from network_interface_info + network_state_change
(before/after), enriches SSIDs from wifi_signal_info, infers missing
post-reconnect SSIDs via matching gateway, and flags probable smartphone
hotspots from vendor-default subnets (Apple 172.20.10.x, Windows
192.168.137.x, legacy Android 192.168.43.x) - always as a heuristic badge.
Axis is honest: linear per segment, minimum width for short segments,
boundary ticks show real times. Mobile scrolls horizontally.

Entry point: "Network timeline" deep link in DeviceDetailsCard's Network
section via new networkTimelineUrl() builder. Route registered in the SWA
config (exact routes before the /sessions/* wildcard, incl. the index.txt
RSC route) and excluded from LegacyPathRedirect's /sessions/{id} rewrite
via the new static-siblings set (test pinned).

Planned follow-up once the agent emits them: clock-change events (rebase
axis into clock eras) and standby/Modern-Standby events (render sleep as
its own segment kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:26:49 +02:00
Oliver Kieselbach c9701301ea feat(scripts): Cloud Shell ambient-identity sign-in for Grant-AutopilotMonitorAddOn
Try Connect-MgGraph -Identity first (Azure Cloud Shell issues a delegated
token for the signed-in admin, avoiding the device-code flow that
Conditional Access commonly blocks), probe Graph readability, and fall
back to interactive sign-in with explicit scopes otherwise. The
AppRoleAssignment.ReadWrite.All scope guard now applies only to the
interactive path, since ambient-identity tokens carry a fixed scope set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:24:31 +02:00
Oliver Kieselbach ba2a390a83 fix(agent): battery-instance fallback for the dead Win32_PowerManagementEvent provider
On Windows 11 24H2/25H2 (observed on builds 26200 and 26220, session
161b838c) the MS_Power_Management_Event_Provider is still registered in the
WMI repository but activating the extrinsic push query fails with
WBEM_E_NOT_FOUND - the legacy provider no longer loads on exactly the OS
builds new devices enroll with, so the power watcher degraded fleet-wide.

Arm chain: try the push event first (still alive on older builds), then
fall back to the intrinsic __InstanceModificationEvent WITHIN 30 on
Win32_Battery. The WITHIN sampling runs inside WinMgmt, not in the agent;
the handler path is unchanged (payload ignored, re-probe + diff), worst-case
detection latency ~35s incl. debounce. collector_degraded now fires only
when BOTH attempts fail; the arm log line names the active mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:49:03 +02:00
477 changed files with 35104 additions and 3672 deletions
+8 -3
View File
@@ -11,7 +11,11 @@ env:
ACR_NAME: autopilotmonitoreuacr
CONTAINER_APP_NAME: autopilotmonitor-mcp
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
IMAGE_TAG: ${{ github.sha }}
# Unique per RUN, not per commit: the docs corpus is baked into the image, so a
# docs-only redeploy at an unchanged code sha must still produce a new image
# reference — with a sha-only tag `az containerapp update` saw the same image
# and silently created no revision (2026-08-28).
IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }}
HEALTH_URL: https://mcp.autopilotmonitor.com/health
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
@@ -28,8 +32,9 @@ jobs:
# baked into the image as the search_docs corpus (see the Dockerfile). No
# token is needed. Checking it out here rather than vendoring it keeps the
# docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
# captured below so that staleness is checkable.
# RUNNING SERVER; this workflow must be re-run (IMAGE_TAG is per run so the
# 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
with:
+5 -1
View File
@@ -94,7 +94,11 @@ batch, the counter reconcile's existing event-partition scan additionally collec
rest (`CmTraceSkewTripwire`, hooked in `EventIngestProcessor`). If
`median(Δ_IME) − median(Δ_other)` is a clean 15-minute multiple (residual < 2 min — the
same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct
upload batches per side, a `CmTraceTimeSkewRegression` ops event fires (category Agent,
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
+20 -1
View File
@@ -128,6 +128,15 @@ user-ESP Install-intent apps are still pending (IME logs "Completed user session
*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`,
@@ -166,6 +175,11 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
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.
@@ -202,7 +216,12 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
- `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`. A `Post` issued *by the worker thread itself*
(the `EffectRunner` posting `ClassifierVerdictIssued` from inside `ApplyStep`) never
enters the bounded queue: it goes to a worker-local follow-up queue that is drained,
ahead of the channel, right after the current item — the worker is the queue's only
consumer, so blocking on a full channel there would freeze the pipeline for the rest
of the run.
- `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` →
+147
View File
@@ -0,0 +1,147 @@
---
type: concept
title: Diagnostics Package
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
resource: agent
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
timestamp: 2026-08-23
---
# Schema
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
## Archive layout
```
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
<built-in sections> catalog order, see below
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
package-manifest.txt every packaging decision (always written)
_TRUNCATED.txt only when a cap skipped something
```
## The built-in catalog is the single source of truth
Everything the package collects **before** any configured path is data, not code paths:
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
The agent iterates it in order; the backend serves the same list to the portal
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
agent does. There is no second copy to drift.
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
| --- | --- | --- | --- | --- | --- |
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
name. Two sections may share a ZIP folder only when they read different source folders
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
### Conditions
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
effective configuration and the enrollment scenario; unknown values fail closed.
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
No ConfigVersion bump — no new wire field.
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
fallback rules count as Classic. The probe is a registry read, evaluated once per build
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
treated as Classic and logged — the evtx export is skipped, never the package. The channel
is exclusively locked while active, so the existing `wevtutil epl` export path
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
interactive user exists — identical to configured paths). Active ones log
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
`ADDED / NO MATCH / FOLDER MISSING` lines.
## Built-ins bypass the configured-path guards — on purpose
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
exactly like the five sections that never went through the guard before — and
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
## Configured paths: global ∪ tenant
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
same-named folder share one ZIP folder.
## Portal surfaces
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
neutral condition pills) above the editable global list; add-row first.
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
read-only, then the tenant's own entries; every role with a tenant membership can read the
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
full values in tooltips.
## Test seams
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
because Shared has no test project.
# Examples
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
ImeBootstrapperEventLog`. A tenant that had added
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
entry is removed.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
+113
View File
@@ -0,0 +1,113 @@
---
type: Concept
title: DO GroupId Auto-Set — Delivery Optimization peering group from a network fingerprint
description: EnableDoGroupIdAutoSet (ConfigVersion 40) makes the agent set the DOGroupId policy value to a deterministic GUID derived from the default gateway's IPv4 address and MAC (RealmJoin-compatible byte layout), so devices on the same local network form one DO peering group during enrollment; foreign DOGroupId/DOGroupIdSource policies always win.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs
tags:
- agent
- delivery-optimization
- network
- tenant-configuration
timestamp: 2026-08-21T00:00:00+02:00
---
# DO GroupId Auto-Set
## Schema
### The problem
With Delivery Optimization download mode Group (2), peering is scoped by GroupId. When no
GroupId is configured, Windows derives it from AD site → authenticated domain SID → Entra
tenant ID — for a cloud-native tenant that means **one tenant-wide group**, so "peers on
the same network" degrades to "peers anywhere in the tenant" and DO's cloud service has to
match them by public IP. A GroupId derived from the local network makes the group exactly
"devices behind the same gateway" — including branch offices behind NAT. RealmJoin ships
this as its network-fingerprint GroupId; our agent is on the device even earlier (during
ESP, when the big app downloads actually happen), so it can seed the same value for
Autopilot enrollments.
### The setting
`TenantConfiguration.EnableDoGroupIdAutoSet` (`bool?`, default null→false), served as
`AgentConfigResponse.EnableDoGroupIdAutoSet` — **ConfigVersion 40**. Portal: Settings →
Agent → Settings, toggle "Set Delivery Optimization Group ID", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report. `RemoteConfigMerger` maps the bool 1:1; it survives the offline
config cache like every plain feature flag.
The agent does NOT configure `DODownloadMode` — the fingerprint GroupId only has an
effect when the tenant already deploys download mode Group (2) via Intune/GPO.
### The fingerprint
`DoGroupIdService.ComputeGroupId(ipv4Bytes, macBytes)` builds a 16-byte GUID:
```
{101,48,6} + {0,0,0} + {ip[1], 0, ip[2], ip[3]} + mac[0..5] → new Guid(byte[16])
```
Gateway discovery reuses the shared `NetworkInterfaceLocator` heuristic (Up, not
Loopback/Tunnel, non-0.0.0.0 gateway; IPv4 gateway required), the gateway MAC comes from
`ArpNativeMethods` (`iphlpapi!SendARP`). The byte layout — including the IP shuffle and
the zero padding — is **RealmJoin-compatible and must never change**: devices managed by
RealmJoin on the same network compute the identical GUID and peer with ours. The registry
value is written as `Guid.ToString()` (braceless lowercase), identical to RealmJoin's
`SetValue(name, Guid)` string form. Deliberate divergence from RealmJoin: **no
random-GUID fallback** — a random per-device ID would isolate the device in a one-member
group, which is worse than the OS default grouping, so no gateway/no MAC means skip.
### Ownership + conflict rules (never fight a foreign policy)
Target: `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization\DOGroupId`
(64-bit registry view — the Policies store is WOW64-redirected; key paths reused from
`DoThrottlePolicyReader`). Only this GPO-store value is written; the legacy pre-1607
`GroupId` value name is intentionally not.
Skip conditions (Info event with `skipReason`, nothing written):
| skipReason | Meaning |
|---|---|
| `no_ipv4_gateway` | No active interface with an IPv4 default gateway |
| `arp_failed` | Gateway MAC could not be resolved via SendARP |
| `group_id_source_policy` | `DOGroupIdSource` configured in GPO or MDM store (dynamic group source wins) |
| `mdm_policy_present` | `DOGroupId` managed via the Policy CSP store (Intune owns it) |
| `foreign_value_present` | Existing GPO value neither matches the computed GUID nor the one we last wrote |
Ownership is tracked through the `StartupEventGate` fingerprint for the
`do_group_id_auto_set` key: `written:<guid>` records the GUID we last wrote, so after a
gateway change the agent recognizes its own stale value and updates it — while a value
written by anyone else is never overwritten (`DecideAction`: empty→write, equal→no-op,
ours→update, else→skip; unparseable existing values count as foreign). Every write is
verified by readback.
### The event
One `do_group_id_auto_set` event per outcome change (Source `StartupEnvironmentProbes`,
gated on the fingerprint — unchanged reruns across reboots stay silent; errors never
latch, so each failing start keeps its retry trail). Severity: Info for
success/skip, Warning for errors. Data: `groupId`, `previousValue`, `gatewayIp`,
`gatewayMac`, `written`, `skipped`, `skipReason`, `success`, `error`.
## Examples
First boot on a network with gateway `192.168.178.1` / MAC `00-11-22-33-44-55`:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId set to
00063065-0000-00a8-b201-001122334455 (gateway 192.168.178.1)
```
Tenant already deploys a DO GroupId via Intune:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId not set: mdm_policy_present
```
## Citations
* Service (fingerprint, ownership, write+readback): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs`
* ARP interop: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ArpNativeMethods.cs`
* Probe wiring + event + fingerprint dedup: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* DO policy stores (key paths, Registry64 rationale): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs`
+4
View File
@@ -5,9 +5,13 @@ 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.
+3 -1
View File
@@ -109,7 +109,9 @@ telemetry directly:
```
Collector callback
→ SignalAdapter (dual emission: decision signal + InformationalEvent)
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer)
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer;
posts from the worker thread itself bypass the
queue via a worker-local follow-up queue)
→ single worker thread:
assign ordinals → SignalLog.Append (durable) ← source of truth
→ DecisionEngine.Reduce → apply step/effects
+15 -4
View File
@@ -22,10 +22,21 @@ invisible. The watcher closes that gap **event-driven, with zero polling**.
# Mechanism
- `PowerStateWatcherHost` (`Orchestration/Hosts`) subscribes to the WMI push event
`SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE:
fires on AC↔DC switches and battery-percentage steps, granularity set by the battery
driver, typically 1–3%). The event carries no payload — each arrival re-probes via the
- `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
+110
View File
@@ -0,0 +1,110 @@
---
type: Concept
title: System Timeline Watcher — clock steps and sleep episodes from the System event log
description: How the agent turns two enrollment blind spots into timeline ground truth via one EventLogWatcher on the System channel — system_clock_changed (Kernel-General 1, oldTime/newTime/timeDeltaMs/process) and system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate, Kernel-Power 507 Modern Standby) — including backfill of pre-agent records, the payload-times-are-authoritative rule, SleepSpans in time attribution, the web clock-set jump cause, and ANALYZE-DEV-012.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs
tags:
- agent
- eventlog
- time
- clock-skew
- standby
- collector
- time-attribution
- analyze-rules
timestamp: 2026-08-21T00:00:00+02:00
---
# System Timeline Watcher
Two things silently distort every session timeline and its wall-clock duration:
1. **The OS clock gets stepped** — classically w32time correcting a wrong BIOS clock early
in OOBE, but also tzutil/MDM actions. Before this watcher the jump was only *inferred*
(TimeJumpBadge, `clock_skew`/DEV-008); Windows records the ground truth.
2. **The device sleeps mid-enrollment** — the timeline shows an unexplained gap, the
duration looks inflated, and nothing says "the device was simply asleep for 56 minutes".
`SystemTimelineTracker` (hosted by `SystemTimelineWatcherHost`, config flag
`Collectors.EnableSystemTimelineWatcher`, ConfigVersion 39) subscribes to the **System**
channel with one provider-qualified XPath and forwards both facts as events.
# Sources and events
| Source (System channel) | Emitted event | Key payload |
|---|---|---|
| `Microsoft-Windows-Kernel-General` EventID 1 | `system_clock_changed` | `oldTime`, `newTime` (ISO-8601), signed `timeDeltaMs`, `reason`/`reasonText` (1=`application_set`, 2=`hardware_clock_sync`), `processName`/`processId` |
| `Microsoft-Windows-Power-Troubleshooter` EventID 1 | `system_sleep_episode` | `kind`=`sleep`/`hibernate` (EffectiveState 5 ⇒ hibernate), `enteredAt`=SleepTime, `exitedAt`=WakeTime, `durationSeconds`, `wakeSourceType`/`wakeSourceText` |
| `Microsoft-Windows-Kernel-Power` EventID 507 | `system_sleep_episode` | `kind`=`modern_standby`, `exitedAt`=TimeCreated, `enteredAt`=exit − DurationInUs, `durationSeconds` (scenario), `sleepDurationSeconds` (real sleep), `reason`, `onAcPower`, `batteryRemainingCapacityOnExit` |
**506 is deliberately not consumed.** 507 carries the full episode; pairing 506/507 would
need an orphan-tolerant state machine across agent restarts for zero information gain — an
episode without a 507 never completed and would not be emitted under any design.
# Thresholds (constants, not knobs)
- `MinClockDeltaMs = 2000` — w32time logs Kernel-General 1 for routine **1 ms micro-slews**
(verified live, several per hour); only genuine steps are emitted. Suppressed records are
still claimed by the dedup watermark.
- `ClockDeltaWarningMs = 5 min` — larger steps are Warning + immediate upload (can break
token/cert validation windows; matches the web TimeJumpBadge threshold).
- `MinSleepDurationSeconds = 60` — **Modern Standby only**: every screen-off scenario logs a
506/507 pair, including seconds-long ones with `SleepDurationInUs 0` (`SleepEntered=false`);
the single duration gate suppresses both. Classic Power-Troubleshooter episodes have no
floor — they only exist for genuinely completed S3/S4 transitions.
# Payload times are authoritative
The decisive clock correction happens exactly when the device clock is untrustworthy, and
the backend clamps implausible event timestamps (±168 h/24 h) to server time. Every semantic
instant therefore travels as an explicit ISO-8601 payload field (`oldTime`/`newTime`,
`enteredAt`/`exitedAt`); consumers must never read the event's own `Timestamp` for these.
The event `Timestamp` is set to the record's TimeCreated (WindowsUpdateTracker backfill
convention) so rows land where the wake/step happened.
# Backfill and dedup
Template: `WindowsUpdateTracker`. `LoadWatermark → arm watcher → backfill` with lookback
`SystemEventBackfillLookbackMinutes` (default 1440 = 24 h; 0 disables). The System .evtx
persists across OOBE reboots, so the pre-agent w32time correction and pre-agent standby
episodes are recovered with `backfilled=true`. Cross-restart dedup via RecordId watermark
(`system-timeline-watermark.json` in the state directory) plus an intra-run `HashSet` —
deliberately **not** a high-water mark, which would drop exactly the older, never-emitted
pre-agent records (the live watcher arms before the backfill scan).
# Non-activity classification (both sides)
Both event types are environment observation, not enrollment progress:
`SignalActivityClassifier.NonActivityEventTypes` (agent — must not reset idle/stall clocks)
and `EventIngestProcessor.IsPeriodicOrStallEvent` (backend — a device that slept through its
stall must stay Stalled).
# Consumers
- **Web timeline** (`EventTimeline.tsx`): badge blocks for both events; the TimeJumpBadge
gains the ground-truth cause `clock-set` — `classifyTimeJump` receives the session's
`system_clock_changed` deltas and matches a backward display step against a backward clock
set within max(60 s, 20 %) (`lib/timeProvenance.ts`).
- **Duration story** (`SessionInfoCard` via `useSessionDerivedData.standbySeconds`): the
wall-clock duration deliberately keeps the pause (the enrollment really took that long);
a `· 🌙 56m standby` note explains it. Episodes are dedup'd on `enteredAt`.
- **Time attribution** (`TimeAttributionCalculator.BuildSleepSpans`, AttributionVersion 3):
`SleepSpan`s mirror `RebootSpan`s — cross-cutting annotations clipped to the observation
windows (a WhiteGlove-pause episode contributes only its in-window flanks), never a slice
of the wall-clock partition. Persisted as `SleepSeconds`/`SleepSpansJson`; rendered as the
standby chip in `TimeAttributionLane`.
- **ANALYZE-DEV-012** (info, `trigger: single`, `on_event:system_sleep_episode`): fires once
per session for episodes ≥ 5 min. Deliberately the ONLY new rule: clock issues already
have DEV-008 (`clock_skew`) — a second clock rule would double-report the same action item
(user decision 2026-08-21); clock steps stay timeline ground truth with badges instead.
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/SystemTimelineWatcherHost.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` (`BuildSleepSpans`)
- `src/Web/autopilot-monitor-web/lib/timeProvenance.ts` (`clock-set`, `findExplainingClockChange`)
- `rules/analyze/ANALYZE-DEV-012.json`
- Live payload evidence (2026-08-21, Win11 26220): Kernel-General 1 with `TimeDeltaInMs=1`
micro-slews; Kernel-Power 507 with `SleepDurationInUs=2324095807`; Power-Troubleshooter 1
with `SleepTime`/`WakeTime` across a hibernate.
+185
View File
@@ -0,0 +1,185 @@
---
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)
@@ -0,0 +1,136 @@
---
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)
+165
View File
@@ -0,0 +1,165 @@
---
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 — and resolve the identity themselves
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
oid is pinned on the first sign-in.
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
domain the platform has never seen.
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
`homeTenantId` / `objectId` 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 unknown: a person who has never signed in has no history to resolve from, and
the operator granting an external MSP admin has no directory access to the partner tenant. The first
sign-in from the bound 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 portal surfaces this in **Tenant Management → Edit
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
themselves carry no identity fields — the binding is a background property of the grant.
## 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/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
* `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.
+270
View File
@@ -0,0 +1,270 @@
---
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-29T00: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.
# Certificate transport and proof of possession
Every stage above validates certificate *content*: chain, validity window, EKU, tenant extension.
None of them proves the caller holds the private key — a certificate is public material (sent in
clear in a TLS 1.2 client-auth handshake, readable from the machine store by any local user, present
on resold hardware). Proof of possession comes from the TLS handshake alone: the platform runs
`clientCertMode=Required` on every path not listed in its `clientCertExclusionPaths`, forwards the
handshake certificate as `X-ARR-ClientCert`, and **strips any inbound copy of that header — on
excluded paths too** (verified 2026-08-29 with a spoofed header against `/api/bootstrap/config`:
the validator saw "No certificate provided"). So `X-ARR-ClientCert` is trustworthy exactly because
the caller cannot write it.
Two consequences shape `SecurityValidator`:
* **No other certificate header is ever read.** A former local-dev fallback (`X-Client-Certificate`)
accepted certificate bytes from a client-writable header, which decouples the certificate from
key possession: anyone holding a device's public certificate would authenticate as that device.
Removed 2026-08-29. Local development sets `X-ARR-ClientCert` directly — Core Tools does not strip
it.
* **Bootstrap routes are token-only, fail-closed.** `/api/bootstrap/*` is in the platform's
exclusion list so cert-less OOBE agents can reach it — which also means nothing on those paths
proves a handshake happened. The token is therefore the sole credential there:
`SecurityValidator.IsBootstrapRoute` (path prefix, mirroring the exclusion entry so no function
can forget to opt in) rejects a missing or **empty** `X-Bootstrap-Token`, and a missing
`BootstrapSessionService`, with 401 before the certificate stage can run. The live gap this
closes: the function-level `Headers.Contains` gate accepted an empty header while the validator
treated it as "no token" and fell through to the certificate stage, where the fallback header
above was parsed. Bootstrap tokens are never accepted as a *replacement* for the certificate on
platform-enforced routes either — there the cert requirement is already satisfied by the
handshake before the request reaches code.
The exclusion list is portal-only state (no IaC). Adding `/api/agent` to it, or moving a
device-authenticated function under an excluded prefix, would silently reopen the header path;
`BootstrapRouteFailClosedTests` pins the code half of this contract.
**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).
+2 -1
View File
@@ -78,7 +78,8 @@ Durations shown for attempts are the sessions' authoritative `DurationSeconds` v
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
success session (the platform's StartedAt-date convention; late terminals converge via
the sweep). All counts are **additive**, so a window rate is the ratio of summed daily
the sweep, which slices the tick's shared projected window scan — see
`time-attribution.md`). All counts are **additive**, so a window rate is the ratio of summed daily
rows — there is no rolling-window row (unlike the median-based time-attribution
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
on purpose (the UI needs the n for "insufficient data (n=…)").
+114
View File
@@ -0,0 +1,114 @@
---
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/`)
@@ -0,0 +1,93 @@
---
type: concept
title: Plan Downgrade Policy & the Retention Grace Period
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
resource: backend
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
timestamp: 2026-08-22
---
# Schema
## The downgrade model: gate, don't destroy
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
(≤ ~5 min config cache):
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
assignments are never touched. Resurrects on re-upgrade.
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
**Retention is the one exception**: the retention sweep (12-hourly
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
irreversibly cascade-deleting the 90–365-day band on the next tick.
## The retention grace period (30 days)
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
swept, grace or not.
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
returns null while effectively Pro; grace end = latest anchor + 30d):
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
the trial timestamp disappears and could not anchor). Cleared on any transition back to
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
resets the grace clock.
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
(read-time, like edition resolution). A planTier downgrade under a still-active trial
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
anchors the grace then.
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
which is correct: no tenant had been downgraded before the feature existed.
## Ops visibility (dual-registered event types)
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
response body also returns `retentionGraceEndsUtc`.
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
so Telegram alert rules can route them.
## Deliberate non-goals
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
grace a tenant admin cannot SET a value above the Community cap. The grace protects
existing data; new writes follow the new plan.
* The feature-flags endpoint keeps publishing the plain edition cap
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
+157
View File
@@ -0,0 +1,157 @@
---
type: Concept
title: Session Owner Binding — Session-Level Authorization for Agent Writes
description: Why tenant-level agent authentication (any device certificate, or a bootstrap token) was enough to rewrite every session in the tenant, how each Sessions row is now bound to the device identity that created it, the rule matrix that keeps every legitimate lifecycle working, the shadow-then-enforce rollout, and why enforcement needs the agent to rotate its session id rather than count an auth failure.
resource: src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs
tags:
- backend
- security
- authorization
- sessions
- agent
- multi-tenant
timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
Agent endpoints authenticate at **tenant** granularity: a chain-valid Intune MDM certificate whose
tenant stamp matches the request (see [client-cert-tenant-binding.md](client-cert-tenant-binding.md)),
or a bootstrap token minted for the tenant. They authorize writes at **session** granularity using a
session id the caller supplies — `IngestTelemetryFunction` takes it from the `PartitionKey` of the
first item, `RegisterSessionFunction` from the body, `ReportAgentErrorFunction` from the body. Until
2026-08-29 nothing on the Sessions row recorded which device created it, so nothing could be compared.
The consequence: one enrolled device — or anyone who saw a 6-character bootstrap short code — could,
for every other session of the tenant, forge `enrollment_complete` / failure events (flipping the live
status, firing webhooks and SLA logic), pollute the timeline, re-register the session with different
device metadata (which also redirects the Progress-Portal serial-knowledge proof), and — worst — send a
Signal-only batch that **fetches and clears the victim's pending ServerActions**, silently stealing
admin commands such as diagnostics collection. Session ids are GUIDs, but they travel in portal URLs
and tenant SignalR messages; they were never meant to be a capability.
# Mechanism
## Owner on the row
`SessionOwner` (Shared) is stamped by the backend from the validated caller identity — never from
agent input — and lives in six primary-only Sessions columns (`OwnerKind`, `OwnerThumbprint`,
`OwnerDeviceId`, `OwnerBootstrapCode`, `OwnerSerial`, `OwnerBoundAt`). They are listed in
`SessionIndexFieldManifest.PrimaryOnly`: never mirrored to `SessionsIndex`, never on `SessionSummary`,
so no certificate identity travels to clients.
| Auth path | Identity that binds | Proven by |
|---|---|---|
| Certificate | `OwnerThumbprint` + `OwnerDeviceId` (Intune device id from the certificate CN, lower-case GUID) | TLS handshake; the CN survives a certificate re-issue |
| Bootstrap token | `OwnerBootstrapCode` + `OwnerSerial` | Token possession + the serial the caller announced (header, unverified) |
`SecurityValidationResult.IntuneDeviceId` is new: the CN was already extracted for the Cloud PC
validator, now it is surfaced on the result for every certificate-authenticated request.
## Rule matrix
`SessionOwnershipPolicy.Evaluate(existingRow, validation, now)` is pure. It returns an outcome, the
owner to stamp (if any) and whether the caller's serial equals the row's serial.
| Caller | Row owner | Outcome | Stamps | Would reject |
|---|---|---|---|---|
| any | no row | `Fresh` | caller | no |
| any | no owner columns (legacy), serial equal | `ClaimLegacy` | caller | no |
| any | no owner columns, serial differs | `LegacySerialMismatch` | — | **yes** |
| any | — (validation carried neither thumbprint nor code) | `CallerUnidentified` | — | no |
| Cert | Cert, same thumbprint | `Match` | — | no |
| Cert | Cert, other thumbprint, same device id | `RebindCertRotation` | caller | no |
| Cert | Cert, other thumbprint and device id | `MismatchCert` (+ `serialMatch`) | — | **yes** |
| Cert | Bootstrap, same serial | `RebindBootstrapHandoff` | caller | no |
| Cert | Bootstrap, other serial | `MismatchBootstrapOwned` | — | **yes** |
| Bootstrap | Bootstrap, same code + serial | `Match` | — | no |
| Bootstrap | Bootstrap, otherwise | `MismatchBootstrap` | — | **yes** |
| Bootstrap | Cert | `DowngradeToBootstrap` | — | **yes** |
The tolerated rows are the legitimate lifecycle: agent restart and reboot (same certificate),
WhiteGlove Part 2 (same certificate), the install→runtime handoff where a bootstrap-registered session
continues under the MDM certificate once it exists, a re-issued certificate of the same Intune device,
and rows that predate the binding.
`MismatchCert` with `serialMatch=true` is the one shape that is *not* an attack: an Intune
re-enrollment **without a wipe**. `session.id` survives in `%ProgramData%\AutopilotMonitor`, but the
device gets a new Intune device id and therefore a new certificate identity. Under enforcement this
would be refused — which is why enforcement needs the agent-side rotation below.
## Where it runs
`SessionOwnerBindingObserver` is the single side-effect carrier; the three call sites hand it the
row they already hold:
* `RegisterSessionFunction.ProcessRegisterAsync` — the cascade-delete guard now returns the row
(`EnsureWritableAndGetRowAsync`); the decision's owner goes into `StoreSessionAsync(registration,
ownerToStamp)`, whose Replace preserves an existing owner when nothing new is stamped (including a
Merge-stamped owner that landed after the initial read — the CAS re-read selects the owner columns).
The bootstrap wrapper inherits this.
* `IngestTelemetryFunction.Run` — evaluated against `guardSessionRow` (zero extra reads) before
`PersistItemsAsync`, so the Signal-only path that serves pending ServerActions is covered. Legacy
claims and rebinds are written with `UpdateSessionOwnerAsync` (Merge, fail-soft).
* `ReportAgentErrorFunction` and its bootstrap wrapper — one point-read, observe only.
## Shadow telemetry (stage 1)
Same three carriers as the cert-tenant binding:
* **Denominator** — `requests | extend o = tostring(customDimensions.SessionOwnerBinding) | where
isnotempty(o) | summarize count() by o` (request-row dimension via `RequestTelemetryMiddleware`;
worker `LogInformation` never reaches App Insights).
* **Numerator** — `traces | where message startswith "AgentSessionOwnerBinding"` — one Warning per
non-Match/non-Fresh request with `outcome`, `wouldReject`, `callerKind`, `ownerKind`, `serialMatch`,
`endpoint`, `ver`.
* **Operator signal** — ops event `SessionOwnerMismatch` (Security / Warning, dual-registered in the
admin alert-rule catalog) for would-reject outcomes, throttled to one per session+outcome per hour.
`serialMatch=true` counts re-enrollments without wipe; `serialMatch=false` is a foreign device.
Nothing is refused in stage 1. `SessionOwnershipPolicy` deliberately has no `Rejects` member
(pinned by `SessionOwnershipPolicyTests.Stage1_has_no_Rejects_rule`).
## Enforcement contract (stage 2, not yet built)
* Backend: `Rejects(outcome)` = the would-reject set; register/ingest/error answer 403 with
`RegisterSessionResponse.ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch`
(`"session_owner_mismatch"`). The code and field already exist so the agent could ship first.
* Agent (shipped dormant with this change): `BackendApiClient` reads `errorCode` off a JSON 401/403
body into `BackendAuthException.ErrorCode`; `SessionRegistrationHelper` reacts to exactly that code
by calling `rotateSession` once and re-registering immediately — **without** feeding
`AuthFailureTracker` (five consecutive 403s would otherwise soft-shutdown a perfectly authorized
agent). `BackendSessionRegistration.RotateSession` rotates `session.id` (keeping
`whiteglove.complete`), re-targets `agentConfig.SessionId` and the `EmergencyReporter`, and drops
`spool.jsonl` + `upload-cursor.json` because their lines carry the refused partition key. The
`agent_started` event then reports `sessionRotated=true` + `previousSessionId`.
* Registration is the first session-scoped call of every agent start and everything after Phase 6
reads `agentConfig.SessionId` live, so a mismatch on ingest can only mean an agent older than the
rotation logic or a genuinely foreign caller.
Switch on only after: the shadow distribution has run for weeks, every `wouldReject=true` is
explained, and the fleet's `X-Agent-Version` is at or above the rotation-capable release. Then add
`Rejects`, delete the stage-1 pin test, update the trust pages (isolation model) and bump their
"Last reviewed" date.
# Residual risk (accepted)
* The serial is a caller-supplied header on both paths. A certificate holder who also knows a victim's
serial can still claim a **legacy** row, or a **bootstrap-owned** row during its handoff window.
* One bootstrap token serves many devices; two devices on the same short code can still write into
each other's session until the certificate handoff closes it.
* `SupersedeOrphanedPredecessorsAsync` is keyed on the announced serial and can still resolve foreign
open sessions to `Incomplete`. Follow-up: restrict to rows without owner or with the same
`OwnerSerial`, and require a minimum age.
None of these are closable without a signature scheme over the request body, which is deliberately
not built (it would duplicate the TLS proof).
# Citations
* `src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs` — matrix, `WouldRejectUnderEnforcement`, row (de)serialization
* `src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs` — carriers, throttle, stamping
* `src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs` — column names and kinds
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `StoreSessionAsync(…, ownerToStamp)`, `UpdateSessionOwnerAsync`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — rotation branch
* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs` — `RotateSession`
* Tests: `SessionOwnershipPolicyTests`, `SessionOwnerBindingObserverTests`, `StoreSessionReregistrationPreserveTests`, agent `SessionRegistrationHelperTests`, `BackendSessionRegistrationTests`, `SessionIdPersistenceTests`, `BackendApiClientAuthClassificationTests`
* Grep marker: `SESSION-OWNER-BINDING`
@@ -0,0 +1,140 @@
---
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
@@ -0,0 +1,67 @@
---
type: Concept
title: Table Schema Sentinel (startup table initialization)
description: Why a cold start no longer issues one CreateTableIfNotExists per registered table - a hash over TableNames.All stored in AdminConfiguration gates the full pass, the SessionsIndex backfill only runs after a full pass and behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
resource: /src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
tags:
- backend
- table-storage
- startup
- maintenance
timestamp: 2026-08-23
---
# Schema
## Problem
`TableInitializerService` (hosted service) called `InitializeTablesAsync` on every process start, which issued `CreateTableIfNotExistsAsync` for every entry of `Constants.TableNames.All` (66 at the time of writing, DoP 8). Each call is a Create POST whose 409 is swallowed - on Flex Consumption with frequent cold starts that was ~9 serial round-trip waves before the first request. It was followed by a SessionsIndex emptiness query whose *error* path returned "empty", so a transient storage error triggered a full Sessions scan with serial upserts - on every scaled-out instance at once.
## Sentinel
* Row `AdminConfiguration` / PK `SchemaSentinel` / RK `tables`, property `TableSchemaHash` (+ `TableCount`, `UpdatedUtc`).
* Value = `ComputeTableSchemaHash(TableNames.All)`: SHA-256 over the ordinal-sorted, newline-joined table names. Derived, never hand-maintained: adding a table to `TableNames.All` changes the hash by construction (`TableLifecycleBucketTests` already forces every constant into `All`).
* `InitializeTablesAsync` (returns `bool fullPassRan`):
1. Point-read the sentinel. Match -> mark initialized, **zero** table calls, return `false`.
2. Missing / stale / unreadable (fresh storage has no AdminConfiguration table yet) -> `EnsureAllTablesAsync` (the old full pass), then upsert the sentinel **only if no table failed**, return `true`. A failed pass leaves `_tablesInitialized = false`, so the next call retries.
* Scale-out: two instances that both see a stale sentinel both run the idempotent pass - harmless.
## SessionsIndex backfill
* Only evaluated when the full pass ran (`fullPassRan == true`): an empty index can only exist on fresh storage, which is exactly the case without a sentinel.
* `IsSessionIndexEmptyAsync` now returns `false` on error (skip), not `true`.
* `TryClaimSessionIndexBackfillAsync`: conditional `AddEntityAsync` on `SchemaSentinel` / `sessionIndexBackfill` - only the winner scans; 409 or any error -> skip. The manual maintenance backfill (`RunManualAsync`) remains the safety net.
## Known gap and repair
A table deleted out-of-band (cleanup skill, portal, tests) is not noticed at startup anymore. `IStorageInitializer.EnsureAllAsync` runs as the first step of the daily timer maintenance (`MaintenanceService.RunAllAsync`), recreating it within 24h. Storage helpers are fail-soft in the meantime.
## Measuring it
`StartupTelemetryService` (hosted service) emits two App Insights metrics on `IHostApplicationLifetime.ApplicationStarted` - i.e. after every hosted service finished `StartAsync`:
* `BackendStartupMs` - process start (`Process.StartTime`) to host ready; includes runtime, DI and all hosted services.
* `BackendTableInitMs` - the `InitializeTablesAsync` slice alone (`TableStorageService.LastInitialization`).
Dimensions: `version`, `commit`, `tableInitFullPass`, `instance`. Metrics bypass adaptive sampling and the worker log level, so they arrive even though Information traces do not. The MCP server logs the same idea as one `{"type":"boot","readyMs":…}` stderr line per start (ContainerAppConsoleLogs_CL, see `.claude/commands/mcp-telemetry.md`).
```kusto
customMetrics
| where name in ("BackendStartupMs", "BackendTableInitMs")
| summarize p50 = percentile(value, 50), p95 = percentile(value, 95), n = count()
by name, version = tostring(customDimensions.version), fullPass = tostring(customDimensions.tableInitFullPass)
```
# Examples
* Deploy that adds a table: first cold start after the deploy runs the full pass once (log: "Table schema sentinel missing or stale"), every later start logs "Table schema sentinel matches (...)".
* Fresh storage account: sentinel read throws TableNotFound -> full pass -> index empty -> one instance claims and backfills.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs` - sentinel, `InitializeTablesAsync`, `EnsureAllTablesAsync`, `TryClaimSessionIndexBackfillAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs` - startup gating
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` - daily `EnsureAllTablesAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/StartupTelemetryService.cs` - `BackendStartupMs` / `BackendTableInitMs`
* `src/McpServer/autopilot-monitor-mcp/src/index.ts` - `{"type":"boot"}` line
* `src/Backend/AutopilotMonitor.Functions.Tests/TableSchemaSentinelTests.cs`, `StartupTelemetryServiceTests.cs`
+89
View File
@@ -0,0 +1,89 @@
---
type: Concept
title: Telemetry Ingest Shaping (App Insights / Log Analytics)
description: What the backend deliberately keeps out of Application Insights and why - one canonical request record (worker copy, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation), expected storage outcomes (404/412/409) and successful SignalR REST calls filtered in the worker, and the query rules that follow.
resource: /src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs
tags:
- backend
- telemetry
- application-insights
- cost
timestamp: 2026-08-23
---
# Telemetry Ingest Shaping
The backend is storage-I/O heavy and runs on the Functions isolated worker, which emits telemetry from TWO processes (host and worker). Left alone, Log Analytics ingestion is dominated by rows nobody reads. This document records what is removed, where, and what that means for queries.
# Schema
## Request telemetry — one canonical record
| Source | Emitted by | Content | Fate |
|---|---|---|---|
| Host HTTP request (`Properties.Source` empty, `Url` set) | Functions host process | Name, duration, status — no tenant/user/correlation | **Dropped at ingestion** by the workspace transformation on `AppRequests` (`infra/appinsights-workspace-transforms.bicep`) |
| Host non-HTTP invocation (`Url` empty) | Functions host process | Timer / queue executions | Kept (the worker middleware never sees them) |
| Worker request (`Properties.Source == 'WorkerMiddleware'`) | `RequestTelemetryMiddleware` | TenantId, UserId, UserRole, CorrelationId, ClientSource, McpToolName, HttpPath | **Kept, unsampled** — the canonical record |
Why two mechanisms:
* The host request item cannot be suppressed from worker code or from `host.json` (`Host.Results` controls log traces, not the request item; verified 2026-06-09). The only lever is a Log Analytics workspace transformation: `source | where tostring(Properties.Source) == 'WorkerMiddleware' or isempty(Url)`.
* `host.json` `samplingSettings.excludedTypes: "Request;Event"` only applies to the host process. The worker's `AddApplicationInsightsTelemetryWorkerService` runs its own adaptive sampling; before 2026-08-23 worker request rows carried `ItemCount` 2–5 on weekdays (≈30 % of individual requests missing, `count()` undercounting by the same share — `sum(ItemCount)` matched the host copy to 1.00 per day). Re-configuring the worker sampling chain did not take effect in the isolated worker (see the L4 note in `Program.cs`). The per-item opt-out does: the middleware sets `SamplingPercentage = 100` on its own `RequestTelemetry`, and the SDK sampling processor passes through every item whose sampling percentage is already set.
## Dependency telemetry — keep only signal
`StorageDependencyFilterProcessor` (worker pipeline) drops:
* successful Azure Storage calls (Table/Queue/Blob) in both shapes — the HTTP shape (`Azure table`, target `*.table.core.windows.net`) and the Azure SDK ActivitySource shape (`InProc | Microsoft.Tables`, `InProc | Microsoft.Storage`);
* storage calls with an **expected outcome**: `404` (point-read miss — EventTypeIndex, BlockedDevices lookups), `412` (ETag precondition), `409` (idempotent insert conflict). HTTP-shape rows carry the code in `ResultCode`; InProc rows only in `Properties["Error"]` (`Status: 404 (`). Before this filter these were 100 % of the remaining "failed" storage rows (~540 MB/week, two rows per call);
* successful Azure SignalR Service REST calls the worker makes itself (`*.service.signalr.net`).
The bulk of SignalR REST traffic (group add/remove per connection, ~470 MB/week) is emitted by the **host** process for the SignalR output binding (`SDKVersion rdddsc:*`) and never passes the worker pipeline — live-verified after the 2026-08-23 deploy (worker rows: 0, host rows: ~560/h). Those are removed by the second data flow of the same workspace transformation: `AppDependencies | where not(Success == true and Target endswith '.service.signalr.net')`.
Everything else passes: storage 429/5xx/auth/timeouts, failed SignalR calls, all HTTP/Graph/NVD/MSRC dependencies, the worker's own `InProc Invoke` span, and every non-dependency item.
## Measured baseline (7 days before the change, `_BilledSize`, `autopilotmonitor-api-eu`)
| Table | Rows | MB | After |
|---|---|---|---|
| AppDependencies | 976k | 1248 | ≈ 240 (SignalR + expected-outcome rows gone) |
| AppRequests host copy | 274k | 323 | ≈ 0 (only timer/queue rows) |
| AppRequests worker copy | 189k | 208 | ≈ 300 (now 100 % of requests) |
| AppTraces | 142k | 137 | unchanged |
| AppEvents | 6k | 4 | unchanged — not worth touching |
Net ≈ −1.3 GB/week with MORE usable data (every request now has tenant context).
# Examples
Counting requests (exact since 2026-08-23; for older data replace `count()` with `sum(itemCount)`):
```kql
requests
| where customDimensions.Source == 'WorkerMiddleware'
| summarize total=count(), failed=countif(success == false) by name
```
Real storage trouble only:
```kql
dependencies
| where type startswith 'InProc | Microsoft.Tables' or target endswith '.table.core.windows.net'
| summarize count() by resultCode, tostring(customDimensions.Error)
```
Post-deploy verification of the per-item sampling bypass (must be `ItemCount == 1` only):
```kql
AppRequests
| where TimeGenerated > ago(1h) and tostring(Properties.Source) == 'WorkerMiddleware'
| summarize rows=count() by ItemCount
```
# Citations
* `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `SamplingPercentage = 100` before `TrackRequest`.
* `src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs` + `StorageDependencyFilterProcessorTests.cs` — the filter contract.
* `src/Backend/AutopilotMonitor.Functions/Program.cs` — the failed L4 worker-sampling attempt and why it must not be retried.
* `infra/appinsights-workspace-transforms.bicep` — the AppRequests transformation DCR, apply/verify/rollback commands.
* `.claude/commands/backend-logs.md`, `session-debug.md` — query conventions.
+65 -1
View File
@@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
resource: backend
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
timestamp: 2026-08-02
timestamp: 2026-08-23
---
# Schema
@@ -58,6 +58,64 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
## 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
@@ -73,3 +131,9 @@ paths never duplicate afterwards.
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
* `src/Web/autopilot-monitor-web/app/admin/components/tenantSearch.ts` — the console's match rules (searchable fields, quoted exact mode, lowercased join)
+5
View File
@@ -105,6 +105,11 @@ the partition).
* Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds
late-terminating sessions into their StartedAt-date aggregates, and enforces version
purity — a stale-version row whose events aged out counts as missing, never mixed.
Reads the tick's **shared window scan** (`MaintenanceService.LoadSweepWindowSessionsAsync`,
one projected 35-day cross-tenant drain via `MaintenanceSweepSessionProjection` that
also feeds the device-journey and verdict-calibration sweeps) — the StartedAt-only
filter is a full-table read in Table Storage, so it is done once per tick, not once per
sweep; `MaintenanceSweepProjectionEquivalenceTests` pins the column set.
* `TimeAttributionAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}|{class}` and
`rolling30|{class}`): per enrollment class (`user_driven`, `whiteglove`, `self_deploying`,
`device_preparation` — never mixed) median/p75/p90 per segment over the fixed six-segment
+176
View File
@@ -0,0 +1,176 @@
---
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)
@@ -0,0 +1,160 @@
---
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
+16 -1
View File
@@ -19,19 +19,23 @@ https://docs.autopilotmonitor.com (separate repository).
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
* [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
@@ -43,14 +47,20 @@ https://docs.autopilotmonitor.com (separate repository).
# 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.
* [Session Owner Binding](backend/session-owner-binding.md) - Agent endpoints authenticate per tenant but write per caller-supplied session id; each Sessions row is now bound to the device identity that created it (cert thumbprint + Intune device id from the CN, or bootstrap code + serial), the rule matrix that keeps restart / WhiteGlove Part 2 / bootstrap→cert handoff / cert re-issue working, the shadow telemetry (`SessionOwnerBinding` request dimension, `AgentSessionOwnerBinding` warning, `SessionOwnerMismatch` ops event), and the enforcement contract where a 403 `session_owner_mismatch` makes the agent rotate its session id instead of counting an auth failure.
* [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.
@@ -59,6 +69,7 @@ https://docs.autopilotmonitor.com (separate repository).
# 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).
@@ -69,6 +80,10 @@ https://docs.autopilotmonitor.com (separate repository).
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
* [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, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
* [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
+109
View File
@@ -1,7 +1,116 @@
# Log
## 2026-08-29 (9)
* **Update**: `agent/decision-engine.md`, `agent/overview.md` — `SignalIngress.Post` is worker-thread re-entrancy-safe: signals the worker posts to itself from inside `ApplyStep` (EffectRunner `ClassifierVerdictIssued`) go to a worker-local follow-up queue drained ahead of the bounded channel instead of blocking on it, so a full channel can no longer deadlock the single consumer.
## 2026-08-29 (8)
* **Creation**: Added `backend/session-owner-binding.md` — SESSION-OWNER-BINDING stage 1 (shadow): Sessions rows carry the creating device identity (`OwnerKind/OwnerThumbprint/OwnerDeviceId/OwnerBootstrapCode/OwnerSerial/OwnerBoundAt`, primary-only), `SessionOwnershipPolicy` evaluates every register / telemetry / error-report request against it, outcomes ride on the request row (`SessionOwnerBinding`), warnings and a throttled `SessionOwnerMismatch` ops event; nothing is refused yet. Agent ships the dormant counterpart: `errorCode` parsing on 401/403, one-shot session rotation on `session_owner_mismatch` without feeding the auth-failure tracker, spool drop, `agent_started.sessionRotated`.
## 2026-08-29 (7)
* **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage.
## 2026-08-29 (6)
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
## 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.
+11 -7
View File
@@ -8,7 +8,7 @@ tags:
- oauth
- entra-id
- security
timestamp: 2026-07-11T10:59:31+02:00
timestamp: 2026-08-29T08:45:00+02:00
---
# MCP OAuth Flow — Who Authenticates Where
@@ -41,9 +41,12 @@ The two identities never mix:
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 registers clients dynamically (RFC 7591, HMAC-signed stateless
`client_id`), enforces the redirect-URI allowlist and PKCE, and exchanges
codes at Entra using its confidential client secret.
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
@@ -54,9 +57,10 @@ Claude Desktop Browser (user's MCP server Entra ID
│ │ │ │
1. │─ POST /mcp ──────────┼───────────────────────▶│ 401 + WWW-Auth │
2. │─ Discovery ──────────┼───────────────────────▶│ RFC 9728/8414 docs │
3. │─ POST /oauth/register┼───────────────────────▶│ client_id (HMAC- │
│ │ │ signed, embeds the │
│ │ │ registered callback) │
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, │
+129
View File
@@ -0,0 +1,129 @@
---
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).
+6
View File
@@ -31,6 +31,12 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
`DiagnosticsPathGuards.IsDiagnosticsPathAllowed` (allowlist `diagnosticsPathPrefixes`, a C#
literal mirrored in the JSON) applies the same hard blocks and the same user-profile
exception to **admin-configured diagnostics paths** only. The diagnostics package's built-in
sections are reviewed code and never pass through it — see
[Diagnostics Package](../agent/diagnostics-package.md).
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
but the hard blocks must hold regardless of configuration:
@@ -0,0 +1,78 @@
// Autopilot-Monitor — Log Analytics workspace transformation (ingestion-time filter)
//
// Two ingestion-time filters. (2) Successful Azure SignalR REST dependencies emitted by the
// HOST process for the SignalR output binding (SDKVersion rdddsc:*) — the worker-side
// StorageDependencyFilterProcessor never sees those. Failed SignalR calls are kept.
// (1) Drops the redundant HOST-side HTTP request record from AppRequests so that exactly one
// canonical request row remains: the worker-side copy emitted by RequestTelemetryMiddleware
// (Properties.Source == 'WorkerMiddleware', carries TenantId/UserId/CorrelationId and is
// sampling-exempt per item). Non-HTTP host invocations (timer/queue triggers) have an empty
// Url and are kept — the worker middleware never sees them.
//
// Why ingestion-time: the host request item is emitted by the Functions host process and
// cannot be suppressed from worker code or host.json (verified 2026-06-09, see Program.cs).
// Live numbers 2026-08-23 (7d): host copy 274k rows / 323 MB, worker copy 189k rows / 208 MB.
//
// Cost note: transformation filtering is free up to 50% of the table's incoming volume; the
// portion beyond that (here ~11% of AppRequests) is billed at the (cheap) data-processing rate.
//
// Apply (two steps; the link is a CLI update = GET+PUT, which keeps retention/SKU intact):
// az deployment group create -g rg-autopilotmonitor-prd-gwc \
// --template-file infra/appinsights-workspace-transforms.bicep \
// --query properties.outputs.dcrId.value -o tsv
// az monitor log-analytics workspace update -g rg-autopilotmonitor-prd-gwc -n AutopilotMonitor \
// --data-collection-rule <dcrId-from-above>
//
// Verify after ~20 min (ingestion latency):
// AppRequests | where TimeGenerated > ago(15m) | where AppRoleName == 'autopilotmonitor-api-eu'
// | summarize rows=count() by source=tostring(Properties.Source), hasUrl=isnotempty(Url)
// → only (WorkerMiddleware, true) and ('', false) buckets remain.
//
// Rollback: az monitor log-analytics workspace update -g <rg> -n AutopilotMonitor
// --set properties.defaultDataCollectionRuleResourceId=null (then delete the DCR)
@description('Log Analytics workspace that backs the workspace-based Application Insights resources.')
param workspaceName string = 'AutopilotMonitor'
param location string = resourceGroup().location
resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
name: workspaceName
}
resource transforms 'Microsoft.Insights/dataCollectionRules@2023-03-11' = {
name: 'dcr-autopilotmonitor-ws-transforms'
location: location
kind: 'WorkspaceTransforms'
properties: {
description: 'AppRequests: keep the enriched worker request record (Properties.Source == WorkerMiddleware) and non-HTTP host invocations (empty Url); drop the host HTTP duplicate.'
destinations: {
logAnalytics: [
{
workspaceResourceId: workspace.id
name: 'ws'
}
]
}
dataFlows: [
{
streams: [ 'Microsoft-Table-AppRequests' ]
destinations: [ 'ws' ]
transformKql: 'source | where tostring(Properties.Source) == \'WorkerMiddleware\' or isempty(Url)'
}
{
// Successful Azure SignalR REST calls (group add/remove per connection) are emitted by
// the HOST process for the SignalR output binding (SDKVersion rdddsc:*), so the worker's
// StorageDependencyFilterProcessor never sees them. Failed SignalR calls are kept.
streams: [ 'Microsoft-Table-AppDependencies' ]
destinations: [ 'ws' ]
transformKql: 'source | where not(Success == true and Target endswith \'.service.signalr.net\')'
}
]
}
}
// A workspace has exactly one transformation DCR, activated by linking it as the workspace's
// defaultDataCollectionRuleResourceId — deliberately NOT modelled here (see Apply above).
output dcrId string = transforms.id
+92
View File
@@ -0,0 +1,92 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-017",
"title": "App Install Blocked by Another Installer (0x80070652)",
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 70,
"conditions": [
{
"signal": "mutex_blocked_install",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "hresultFromWin32",
"operator": "equals",
"value": "-2147023278",
"required": true
},
{
"signal": "installer_exit_1618",
"source": "event_data",
"eventType": "app_install_failed",
"dataField": "exitCode",
"operator": "equals",
"value": "1618",
"required": false
},
{
"signal": "esp_failed_with_mutex_code",
"source": "event_data",
"eventType": "esp_failure_settle_started",
"dataField": "errorCode",
"operator": "equals",
"value": "0x80070652",
"required": false
}
],
"confidenceFactors": [
{
"signal": "installer_exit_1618",
"condition": "exists",
"weight": 10
},
{
"signal": "esp_failed_with_mutex_code",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 65,
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
"remediation": [
{
"title": "Identify the competing installer",
"steps": [
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
]
},
{
"title": "Serialize the install chain",
"steps": [
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
},
{
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
}
],
"tags": [
"apps",
"msiexec",
"mutex",
"0x80070652",
"1618",
"esp"
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:system_sleep_episode"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "slept_during_enrollment",
"source": "event_data",
"eventType": "system_sleep_episode",
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"required": true
}
],
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
"remediation": [
{
"title": "Keep the device awake during enrollment",
"steps": [
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
]
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"power",
"standby",
"sleep",
"environment"
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-NET-001",
"title": "Diagnostics Upload Blocked by Web Security Gateway",
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
"severity": "warning",
"category": "network",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 75,
"conditions": [
{
"signal": "foreign_html_response",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "HTML platform error page",
"required": true
},
{
"signal": "security_gateway_marker",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "regex",
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
"required": true
},
{
"signal": "http_403_block",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "403",
"required": false
}
],
"confidenceFactors": [
{
"signal": "http_403_block",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
"remediation": [
{
"title": "Exempt the upload endpoint in the web gateway",
"steps": [
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
]
},
{
"title": "Verify the fix",
"steps": [
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Windows Autopilot networking requirements",
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
}
],
"tags": [
"network",
"proxy",
"ssl-inspection",
"swg",
"diagnostics"
]
}
+219
View File
@@ -943,6 +943,97 @@
"delivery-gap"
]
},
{
"ruleId": "ANALYZE-APP-017",
"title": "App Install Blocked by Another Installer (0x80070652)",
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 70,
"conditions": [
{
"signal": "mutex_blocked_install",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "hresultFromWin32",
"operator": "equals",
"value": "-2147023278",
"required": true
},
{
"signal": "installer_exit_1618",
"source": "event_data",
"eventType": "app_install_failed",
"dataField": "exitCode",
"operator": "equals",
"value": "1618",
"required": false
},
{
"signal": "esp_failed_with_mutex_code",
"source": "event_data",
"eventType": "esp_failure_settle_started",
"dataField": "errorCode",
"operator": "equals",
"value": "0x80070652",
"required": false
}
],
"confidenceFactors": [
{
"signal": "installer_exit_1618",
"condition": "exists",
"weight": 10
},
{
"signal": "esp_failed_with_mutex_code",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 65,
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
"remediation": [
{
"title": "Identify the competing installer",
"steps": [
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
]
},
{
"title": "Serialize the install chain",
"steps": [
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
},
{
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
}
],
"tags": [
"apps",
"msiexec",
"mutex",
"0x80070652",
"1618",
"esp"
]
},
{
"ruleId": "ANALYZE-CORR-003",
"title": "Proxy Configuration Causing Download Failure",
@@ -1678,6 +1769,54 @@
"environment"
]
},
{
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:system_sleep_episode"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "slept_during_enrollment",
"source": "event_data",
"eventType": "system_sleep_episode",
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"required": true
}
],
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
"remediation": [
{
"title": "Keep the device awake during enrollment",
"steps": [
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
]
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"power",
"standby",
"sleep",
"environment"
]
},
{
"ruleId": "ANALYZE-ENRL-001",
"title": "Enrollment Failed",
@@ -2916,6 +3055,86 @@
"esp"
]
},
{
"ruleId": "ANALYZE-NET-001",
"title": "Diagnostics Upload Blocked by Web Security Gateway",
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
"severity": "warning",
"category": "network",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 75,
"conditions": [
{
"signal": "foreign_html_response",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "HTML platform error page",
"required": true
},
{
"signal": "security_gateway_marker",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "regex",
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
"required": true
},
{
"signal": "http_403_block",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "403",
"required": false
}
],
"confidenceFactors": [
{
"signal": "http_403_block",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
"remediation": [
{
"title": "Exempt the upload endpoint in the web gateway",
"steps": [
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
]
},
{
"title": "Verify the fix",
"steps": [
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Windows Autopilot networking requirements",
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
}
],
"tags": [
"network",
"proxy",
"ssl-inspection",
"swg",
"diagnostics"
]
},
{
"ruleId": "ANALYZE-OFFICE-001",
"title": "Microsoft 365 Apps Install Failed",
+8
View File
@@ -675,6 +675,14 @@
"description": "Detects when IME completes a user session, indicating enrollment has finished for that user.",
"enabled": true
},
{
"patternId": "IME-USER-SESSION-ZERO-APPS",
"category": "always",
"pattern": "\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
"action": "userSessionZeroApps",
"description": "Detects when IME enumerated a user session and found zero targeted Win32 apps. IME then skips its 'Completed user session' line entirely (the zero-app branch in ApplicationPoller continues before that log statement), so this line is the only evidence that user-phase enforcement finished on a device with no user-targeted apps. Treated as equivalent to a completed user session.",
"enabled": true
},
{
"patternId": "IME-WIN32-STATE-1",
"category": "currentPhase",
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-USER-SESSION-ZERO-APPS",
"category": "always",
"pattern": "\\[Win32App\\] Get 0 apps for user session (?<sessionId>\\d+), user id = (?<userId>[a-f0-9\\-]+)",
"action": "userSessionZeroApps",
"description": "Detects when IME enumerated a user session and found zero targeted Win32 apps. IME then skips its 'Completed user session' line entirely (the zero-app branch in ApplicationPoller continues before that log statement), so this line is the only evidence that user-phase enforcement finished on a device with no user-targeted apps. Treated as equivalent to a completed user session.",
"enabled": true
}
+1
View File
@@ -63,6 +63,7 @@
"imeImpersonation",
"imeTokenFailure",
"enrollmentCompleted",
"userSessionZeroApps",
"updateDoTelemetry",
"scriptStarted",
"scriptContext",
@@ -1,4 +1,4 @@
#Requires -Version 5.1
#Requires -Version 5.1
<#
.SYNOPSIS
Grants additional Microsoft Graph application permissions to the Autopilot Monitor
@@ -28,10 +28,18 @@
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
(cert-to-device binding: verifies that the Intune device id carried in the
agent's client certificate is a device THIS tenant actually enrolled --
currently a Global-Admin-only preview that records telemetry and does not
block enrollment)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
.PARAMETER Permissions
Microsoft Graph application permission names to grant directly. Use when the admin UI
rendered a copy-paste command for you, or when you have a specific list in mind.
Microsoft Graph application permission names to grant directly. Use when you copied the
permission list from the admin UI ("Copy permissions"), or when you have a specific list
in mind.
.PARAMETER TenantId
Tenant ID (GUID) or verified domain. Optional -- defaults to the signed-in context.
@@ -47,12 +55,16 @@
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames
.EXAMPLE
# Same effect, using the explicit-permission form (what the admin UI generates):
# Same effect, using the explicit-permission form:
.\Grant-AutopilotMonitorAddOn.ps1 `
-ClientId "<your-client-id>" `
-Permissions "DeviceManagementScripts.Read.All" `
-TenantId "contoso.onmicrosoft.com"
.EXAMPLE
# Grant every optional capability in one run:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features All
.EXAMPLE
# Verify current state only, no changes:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames -VerifyOnly
@@ -65,6 +77,12 @@
Requires:
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
Sign-in behaviour:
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
token for the already-signed-in user) - no device-code prompt, which Conditional Access
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
- The Autopilot Monitor app must be admin-consented in your tenant first
(its service principal must exist before grants can be added).
@@ -80,7 +98,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation', 'IntuneDeviceBinding')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -103,10 +121,15 @@ $ErrorActionPreference = 'Stop'
$FeatureCatalog = @{
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'W365CloudPcValidation' = @('CloudPC.Read.All')
'IntuneDeviceBinding' = @('DeviceManagementManagedDevices.Read.All')
}
# Translate Features -> Permissions when caller used the high-level form.
# 'All' is a script-side meta-value: it expands to every catalog feature.
if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
if ($Features -contains 'All') {
$Features = @($FeatureCatalog.Keys)
}
$Permissions = @()
foreach ($f in $Features) {
$Permissions += $FeatureCatalog[$f]
@@ -133,7 +156,25 @@ if ($TenantId) { $connectParams['TenantId'] = $TenantId }
Write-Host ""
Write-Host "[Auth] Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph @connectParams | Out-Null
# Preferred path: ambient identity. In Azure Cloud Shell the local identity endpoint issues a
# delegated Graph token for the signed-in user, which avoids the device-code flow that
# Conditional Access policies commonly block. Outside Azure this throws and we fall back to
# an interactive sign-in with explicit scopes.
$identityConnected = $false
try {
Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop | Out-Null
# An ambient identity can exist yet hold no Graph permissions (e.g. a plain VM managed
# identity) - probe with a read before committing to this session.
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=id&`$top=1" -ErrorAction Stop | Out-Null
$identityConnected = $true
Write-Host "[Auth] Connected via ambient identity (Azure Cloud Shell / managed identity)." -ForegroundColor Green
}
catch {
if (Get-MgContext) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
Write-Host "[Auth] No usable ambient identity - falling back to interactive sign-in..." -ForegroundColor Yellow
Connect-MgGraph @connectParams | Out-Null
}
$ctx = Get-MgContext
if (-not $ctx) {
@@ -144,7 +185,10 @@ Write-Host "[Auth] Signed in as : $($ctx.Account)"
Write-Host "[Auth] Tenant : $($ctx.TenantId)"
Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
if (-not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
# Scope check only applies to the interactive path: an ambient-identity (Cloud Shell) token
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
# and its effective rights follow the signed-in user's directory role.
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
if (-not $VerifyOnly) { return }
}
@@ -0,0 +1,62 @@
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
/// <summary>
/// Pins the compiled-in fallback rule catalogs (<see cref="BuiltInConfigCatalog"/>):
/// the agent embeds the SAME CI-combined rules/dist artifacts the backend serves, so a
/// no-config/no-cache session still runs full IME tracking. These tests guard the
/// embedding + deserialization end-to-end — a broken LogicalName, a moved resource or a
/// shape change in the combined JSON must fail HERE, not silently strip the fallback
/// back down to zero patterns in the field.
/// </summary>
public class BuiltInConfigCatalogTests
{
[Fact]
public void ImeLogPatterns_LoadFromEmbeddedCatalog_EnabledOnly()
{
var patterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null);
Assert.NotEmpty(patterns);
Assert.All(patterns, p =>
{
Assert.True(p.Enabled);
Assert.False(string.IsNullOrWhiteSpace(p.PatternId));
Assert.False(string.IsNullOrWhiteSpace(p.Pattern));
Assert.False(string.IsNullOrWhiteSpace(p.Action));
Assert.False(string.IsNullOrWhiteSpace(p.Category));
});
Assert.Equal(patterns.Count, patterns.Select(p => p.PatternId).Distinct().Count());
}
[Fact]
public void ImeLogPatterns_CarryCoreTrackingActions()
{
// The app-tracking backbone: without these two actions the ImeLogTracker cannot
// attribute installs to apps or follow ESP phases. Their presence proves the
// deserialization actually populated the action field (a shape drift in the
// combined JSON would otherwise still pass the structural checks above).
var actions = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null)
.Select(p => p.Action).ToHashSet();
Assert.Contains("setCurrentApp", actions);
Assert.Contains("espPhaseDetected", actions);
}
[Fact]
public void GatherRules_LoadFromEmbeddedCatalog_EnabledOnly()
{
// The built-in gather-rule catalog ships mostly disabled rules — only the
// default-enabled ones may reach the fallback config.
var rules = BuiltInConfigCatalog.GetEnabledGatherRules(logger: null);
Assert.All(rules, r =>
{
Assert.True(r.Enabled);
Assert.False(string.IsNullOrWhiteSpace(r.RuleId));
});
}
}
}
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
nameof(AgentConfigResponse.NtpServer),
nameof(AgentConfigResponse.EnableTimezoneAutoSet),
nameof(AgentConfigResponse.EnableDoGroupIdAutoSet),
nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
nameof(AgentConfigResponse.DiagnosticsUploadMode),
nameof(AgentConfigResponse.DiagnosticsLogPaths),
@@ -52,6 +53,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
nameof(AgentConfigResponse.Collectors),
// Merge reads one Analyzers knob (EnableRealmJoinWatcher → diagnostics RealmJoin sections);
// the remaining flags flow untouched to DefaultComponentFactory + AgentAnalyzerManager.
nameof(AgentConfigResponse.Analyzers),
};
/// <summary>
@@ -69,8 +73,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.ImeLogPatterns),
// Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
// Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
nameof(AgentConfigResponse.Analyzers),
// Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
nameof(AgentConfigResponse.LatestAgentSha256),
// Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
EnrollmentSummaryLaunchRetrySeconds = 300,
NtpServer = "pool.ntp.org",
EnableTimezoneAutoSet = true,
EnableDoGroupIdAutoSet = true,
DiagnosticsUploadEnabled = true,
DiagnosticsUploadMode = "OnFailure",
DiagnosticsLogPaths = new List<DiagnosticsLogPath>
@@ -83,6 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(300, agent.EnrollmentSummaryLaunchRetrySeconds);
Assert.Equal("pool.ntp.org", agent.NtpServer);
Assert.True(agent.EnableTimezoneAutoSet);
Assert.True(agent.EnableDoGroupIdAutoSet);
Assert.True(agent.DiagnosticsUploadEnabled);
Assert.Equal("OnFailure", agent.DiagnosticsUploadMode);
Assert.Single(agent.DiagnosticsLogPaths);
@@ -366,6 +368,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
[Fact]
public void Merge_mirrors_analyzers_realmjoin_watcher_onto_agent_config()
{
// The diagnostics package gates its RealmJoin sections on this flag but never
// sees the remote response — the merger carries the one Analyzers knob over.
var agent = NewAgentConfig();
var remote = FullRemote();
remote.Analyzers = new AnalyzerConfiguration { EnableRealmJoinWatcher = true };
RemoteConfigMerger.Merge(agent, remote);
Assert.True(agent.EnableRealmJoinWatcher);
}
[Fact]
public void Merge_clears_realmjoin_watcher_when_remote_analyzers_missing()
{
var agent = NewAgentConfig();
agent.EnableRealmJoinWatcher = true; // stale cached true must not survive
var remote = FullRemote();
remote.Analyzers = null!;
RemoteConfigMerger.Merge(agent, remote);
Assert.False(agent.EnableRealmJoinWatcher);
}
[Fact]
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
{
@@ -114,6 +114,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(1, svc.LastFetchAttempts);
}
[Fact]
public async Task FetchConfig_DefaultsFallback_CarriesBuiltInCatalogs()
{
// A no-config/no-cache session must still get the compiled-in rule catalogs —
// an empty ImeLogPatterns list kills the entire IME tracking pipeline (the
// pre-catalog behaviour this pins against regressing to).
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
var result = await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(0, result.ConfigVersion);
Assert.NotEmpty(result.ImeLogPatterns);
Assert.All(result.ImeLogPatterns, p => Assert.True(p.Enabled));
Assert.All(result.GatherRules, r => Assert.True(r.Enabled));
}
[Fact]
public async Task FetchConfig_EmptyResponse_TreatedAsTransient()
{
@@ -128,6 +144,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(4, svc.LastFetchAttempts);
}
// ── Network-link gate (tenant aebdce78 audit 2026-08-24) ────────────
[Fact]
public async Task FetchConfig_InitialFetch_WaitsForNetworkLink()
{
// The initial startup fetch must gate on the link-level signal so the retry
// budget isn't burned into a NIC that is provably still down (agent relaunched
// at boot, Wi-Fi still associating).
var svc = NewService(BackendBehaviour.SuccessImmediately);
await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(1, svc.NetworkLinkWaits);
}
[Fact]
public async Task FetchConfig_RotatePath_SkipsNetworkLinkGate()
{
// rotate_config runs mid-session on a proven link and must stay latency-bounded.
var svc = NewService(BackendBehaviour.SuccessImmediately);
await svc.FetchConfigAsync(retryOnTransientErrors: false);
Assert.Equal(0, svc.NetworkLinkWaits);
}
// ── Post-registration recovery ──────────────────────────────────────
[Fact]
public async Task TryRecover_AfterFailedFetch_AppliesLiveConfig_KeepsFailureRecord()
{
// The core self-heal contract: registration proved the backend reachable, the
// recovery swaps in the live config — but the LastFetch* observability keeps
// describing the failed startup fetch so the wire event can report both.
var svc = NewService(BackendBehaviour.FailAllThenSucceed);
await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
var recovered = await svc.TryRecoverConfigAsync();
Assert.NotNull(recovered);
Assert.Equal(42, recovered.ConfigVersion);
Assert.Equal(42, svc.CurrentConfig.ConfigVersion);
Assert.True(svc.RecoveredAfterFetchFailure);
// Startup-failure record intact:
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
Assert.Equal(1 + RemoteConfigService.InitialFetchRetryBackoff.Length, svc.LastFetchAttempts);
Assert.Equal(nameof(HttpRequestException), svc.LastFetchFailureType);
}
[Fact]
public async Task TryRecover_AfterSuccessfulFetch_IsNoOp()
{
var svc = NewService(BackendBehaviour.SuccessImmediately);
await svc.FetchConfigAsync(retryOnTransientErrors: true);
var recovered = await svc.TryRecoverConfigAsync();
Assert.Null(recovered);
Assert.False(svc.RecoveredAfterFetchFailure);
}
[Fact]
public async Task TryRecover_BeforeAnyFetch_IsNoOp()
{
var svc = NewService(BackendBehaviour.SuccessImmediately);
var recovered = await svc.TryRecoverConfigAsync();
Assert.Null(recovered);
Assert.False(svc.RecoveredAfterFetchFailure);
}
[Fact]
public async Task TryRecover_WhenBackendStillFailing_KeepsFallback_NeverThrows()
{
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
var fallback = await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(0, fallback.ConfigVersion);
var recovered = await svc.TryRecoverConfigAsync();
Assert.Null(recovered);
Assert.False(svc.RecoveredAfterFetchFailure);
Assert.Equal(0, svc.CurrentConfig.ConfigVersion); // fallback config untouched
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
}
// ── Outcome state defaults ──────────────────────────────────────────
[Fact]
@@ -145,6 +249,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
SuccessImmediately,
AlwaysTransientFailure,
FailTwiceThenSucceed,
/// <summary>All 4 initial attempts fail; the NEXT call (the recovery fetch) succeeds.</summary>
FailAllThenSucceed,
AlwaysAuthFailure401,
AlwaysReturnNull,
}
@@ -177,6 +283,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
_behaviour = behaviour;
}
/// <summary>Number of times the network-link gate seam was awaited.</summary>
public int NetworkLinkWaits { get; private set; }
protected override Task<AgentConfigResponse> CallBackendAsync()
{
_calls++;
@@ -188,6 +297,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
if (_calls < 3)
throw new HttpRequestException("simulated cold-start timeout");
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
case BackendBehaviour.FailAllThenSucceed:
if (_calls <= 1 + RemoteConfigService.InitialFetchRetryBackoff.Length)
throw new HttpRequestException("simulated dead network link");
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
case BackendBehaviour.AlwaysTransientFailure:
throw new HttpRequestException("simulated network failure");
case BackendBehaviour.AlwaysAuthFailure401:
@@ -202,6 +315,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// Zero-delay so the suite finishes in ms instead of the live 10/30/60 s schedule.
protected override Task DelayBetweenAttemptsAsync(TimeSpan delay) => Task.CompletedTask;
// Record + skip the real link poll so the suite is deterministic on offline CI.
protected override Task WaitForNetworkLinkAsync()
{
NetworkLinkWaits++;
return Task.CompletedTask;
}
// In-memory + per-instance cache so a prior test's successful fetch can't
// bleed into a later test that expects UsedDefaults.
private AgentConfigResponse? _cached;
@@ -0,0 +1,199 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25) — zero user-targeted apps starved every
/// user-phase completion probe. The IME zero-app branch returns before it writes the
/// "Completed user session" line, so a device whose user has no targeted Win32 apps never
/// produced the evidence <c>IsImeUserSessionGenuine</c> requires, and the AdvisoryCompletion
/// window failed the enrollment 30 minutes later — with the user at the desktop, Hello
/// provisioned and RealmJoin still installing.
/// <para>
/// IME-USER-SESSION-ZERO-APPS reads the line IME does write in that branch and routes it
/// through the same <see cref="ImeLogTracker.OnUserSessionCompleted"/> callback.
/// </para>
/// </summary>
public sealed class ImeLogTrackerZeroUserAppsTests
{
// Mirrors rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.json. The JSON itself is
// pinned against a real IME line by BuiltInRulesTests in the backend suite.
private const string ZeroAppsPattern =
@"\[Win32App\] Get 0 apps for user session (?<sessionId>\d+), user id = (?<userId>[a-f0-9\-]+)";
private const string EspPhasePattern =
@"\[Win32App\] (?:In|The) EspPhase: (?<espPhase>\w+)";
private const string ZeroAppsLine =
"[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
private static ImeLogTracker BuildTracker(TempDirectory tmp) =>
new ImeLogTracker(
logFolder: tmp.Path,
patterns: new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "IME-USER-SESSION-ZERO-APPS",
Category = "always",
Pattern = ZeroAppsPattern,
Action = "userSessionZeroApps",
},
new ImeLogPattern
{
PatternId = "IME-ESP-PHASE",
Category = "always",
Pattern = EspPhasePattern,
Action = "espPhaseDetected",
},
},
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
[Fact]
public void ZeroApps_line_during_user_phase_fires_user_session_completed()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(1, fired);
Assert.True(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void ZeroApps_line_before_user_phase_does_not_fire()
{
// Firing here would burn the adapter fire-once flag on a pre-sign-in timestamp that
// IsImeUserSessionGenuine (>= AccountSetupEnteredUtc) can never accept.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(0, fired);
Assert.True(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void ZeroApps_observed_early_is_replayed_on_the_user_phase_transition()
{
// IME writes its check-in lines independently of when the agent first parses the ESP
// phase marker, so the evidence can legitimately arrive first. Dropping it would
// leave a zero-app device with no completion evidence at all.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(0, fired);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.Equal(1, fired);
}
[Fact]
public void Repeated_user_phase_matches_do_not_replay_again()
{
// IME re-emits the phase string as it re-evaluates app sets. Only a real transition
// replays; the adapter fire-once flag is the outer guard, but the tracker must not
// spam the callback either.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.Equal(1, fired);
}
[Fact]
public void Nonzero_app_count_lines_do_not_match_the_pattern()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
tracker.ProcessLogMessageForTest(
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
Assert.Equal(0, fired);
Assert.False(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void AreUserEspAppsSettled_true_after_zero_apps_observation()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.True(tracker.AreUserEspAppsSettled());
}
[Fact]
public void AreUserEspAppsSettled_stays_false_on_an_empty_list_without_the_observation()
{
// Mutation proof for the branch above: an empty live list on its own still means
// "phase just cleared / apps not surfaced yet", never vacuously settled.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.False(tracker.AreUserEspAppsSettled());
}
[Fact]
public void AreUserEspAppsSettled_false_outside_the_user_phase_even_after_the_observation()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.True(tracker.UserSessionZeroAppsObserved);
Assert.False(tracker.AreUserEspAppsSettled());
}
[Fact]
public void GetPendingRequiredUserEspInstallApps_is_empty_for_a_zero_app_user_session()
{
// The adapter deferral probe must not park the signal when there is nothing to wait
// for — otherwise the zero-app path would starve exactly like the old one.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Empty(tracker.GetPendingRequiredUserEspInstallApps());
}
}
}
@@ -0,0 +1,140 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the tiered WiFi reader. The WLAN state of the test machine is unknown (CI
/// runners have no WLAN service, dev boxes may be on WiFi with or without location consent),
/// so the live path only pins the fail-soft contract. The diagnostics composer is pinned
/// exactly — it is the only field evidence for a missing wifi_signal_info event, and the
/// ERROR_ACCESS_DENIED hint is what tells an operator that Windows 11 24H2's location gate,
/// not the hardware, swallowed the SSID.
/// </summary>
public sealed class WifiInfoProviderTests
{
[Fact]
public void TryRead_never_throws_and_reports_a_reason_when_it_yields_nothing()
{
var result = WifiInfoProvider.TryRead(null, out var diagnostics);
if (result == null)
{
Assert.False(string.IsNullOrWhiteSpace(diagnostics),
"a null result must always explain itself — that string is the only field evidence");
Assert.Contains("native:", diagnostics);
Assert.Contains("WinRT:", diagnostics);
Assert.Contains("netsh:", diagnostics);
}
else
{
Assert.Null(diagnostics);
// Every tier either carries an SSID or was not supposed to return at all.
Assert.False(result.Ssid == null && result.SignalPercent == null
&& result.RadioType == null && result.Channel == null,
"an all-null payload must surface as null, not as an empty connection");
}
}
// ----- BuildDiagnostics ---------------------------------------------
[Fact]
public void BuildDiagnostics_names_every_tier()
{
var text = WifiInfoProvider.BuildDiagnostics(null, null);
Assert.Contains("no WiFi info from any reader", text);
Assert.Contains("native: no connected WLAN interface", text);
Assert.Contains("WinRT: no SSID", text);
Assert.Contains("netsh: no parsable output", text);
}
[Fact]
public void BuildDiagnostics_passes_through_tier_reasons()
{
var text = WifiInfoProvider.BuildDiagnostics("WlanOpenHandle rc=1062", "WinRT NetworkInformation unavailable");
Assert.Contains("native: WlanOpenHandle rc=1062", text);
Assert.Contains("WinRT: WinRT NetworkInformation unavailable", text);
}
[Fact]
public void BuildDiagnostics_explains_access_denied_as_the_location_gate()
{
var text = WifiInfoProvider.BuildDiagnostics(
"[1cbc79c3-2e31-468a-b949-17fafa6e23ed] query(current_connection) rc=5; ", null);
Assert.Contains("ERROR_ACCESS_DENIED", text);
Assert.Contains("24H2", text);
Assert.Contains("Location services", text);
}
[Fact]
public void BuildDiagnostics_explains_access_denied_without_a_trailing_separator()
{
var text = WifiInfoProvider.BuildDiagnostics("WlanOpenHandle rc=5", null);
Assert.Contains("ERROR_ACCESS_DENIED", text);
}
// ----- IsLocationGateDenied -----------------------------------------
//
// This predicate decides whether the wifi_signal_info payload claims
// "location_services_off" to the operator. A false positive blames a privacy setting
// that is actually fine, so the boundaries are pinned exactly.
[Theory]
[InlineData("[guid] query(current_connection) rc=5; ")]
[InlineData("WlanOpenHandle rc=5")]
public void IsLocationGateDenied_true_only_for_error_access_denied(string nativeDiag)
{
Assert.True(WifiInfoProvider.IsLocationGateDenied(nativeDiag));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("query(current_connection) rc=50; ")]
[InlineData("query(current_connection) rc=5023; ")]
[InlineData("WlanEnumInterfaces rc=1062")]
[InlineData("no WLAN interfaces")]
[InlineData("[guid] state=wlan_interface_state_disconnected; ")]
public void IsLocationGateDenied_false_for_every_other_reason(string? nativeDiag)
{
Assert.False(WifiInfoProvider.IsLocationGateDenied(nativeDiag));
}
[Fact]
public void LocationServicesOff_literal_is_the_published_payload_contract()
{
// Matched verbatim by the portal's WiFi card (wifiDataLimitedReason) — renaming it
// silently drops the hint instead of breaking a build.
Assert.Equal("location_services_off", WifiInfoProvider.LocationServicesOff);
}
[Fact]
public void TryRead_never_claims_the_location_gate_on_the_native_tier()
{
// The native tier only returns when it succeeded, so a full payload must never carry
// a "data limited" reason.
var result = WifiInfoProvider.TryRead(null, out _);
if (result?.SignalPercent != null)
Assert.Null(result.DataLimitedReason);
}
[Theory]
[InlineData("query(current_connection) rc=50; ")]
[InlineData("query(current_connection) rc=5023; ")]
[InlineData("WlanEnumInterfaces rc=1062")]
[InlineData("[guid] state=wlan_interface_state_disconnected; ")]
public void BuildDiagnostics_does_not_blame_the_location_gate_for_other_codes(string nativeDiag)
{
var text = WifiInfoProvider.BuildDiagnostics(nativeDiag, null);
Assert.DoesNotContain("ERROR_ACCESS_DENIED", text);
Assert.Contains($"native: {nativeDiag}", text);
}
}
}
@@ -0,0 +1,48 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the WinRT SSID reader. Its whole value is that it keeps working where the
/// native WLAN API is denied, so the contract that matters here is fail-soft resolution:
/// the type is bound by name at runtime (no winmd), and every step must degrade to null
/// with a reason instead of throwing — a throw would take the Task.Run collector down
/// silently, which is precisely the failure mode this tier exists to end.
/// </summary>
public sealed class WinRtWifiSsidReaderTests
{
[Fact]
public void TryGetConnectedSsid_never_throws()
{
var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out var diagnostics);
if (ssid == null)
Assert.False(string.IsNullOrWhiteSpace(diagnostics), "a null SSID must carry a reason");
else
Assert.Null(diagnostics);
}
[Fact]
public void TryGetConnectedSsid_never_returns_an_empty_ssid()
{
// An AP broadcasting an empty SSID must surface as null, never as "" — the caller
// maps a non-null SSID straight into the wifi_signal_info payload.
var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out _);
Assert.True(ssid == null || ssid.Length > 0);
}
[Fact]
public void TryGetConnectedSsid_is_stable_across_calls()
{
// Reflection members are resolved once and cached; a second call must take the
// cached path and behave identically.
var first = WinRtWifiSsidReader.TryGetConnectedSsid(out _);
var second = WinRtWifiSsidReader.TryGetConnectedSsid(out _);
Assert.Equal(first, second);
}
}
}
@@ -1,9 +1,11 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
@@ -46,7 +48,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Directory.CreateDirectory(SpoolFolder);
}
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
public DiagnosticsPackageService Build(
System.Action<AgentConfiguration>? mutateConfig = null,
IReadOnlyDictionary<string, string>? sectionFolderOverrides = null,
System.Func<bool>? devicePreparationProbe = null)
{
// BackendApiClient is required by the public ctor but BuildArchiveBytes
// never touches it — construct with a throwaway HttpClient. Tests only
@@ -71,7 +76,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
imeLogFolderOverride: ImeTmp.Path,
agentStateFolderOverride: StateFolder,
agentSpoolFolderOverride: SpoolFolder,
agentDataFolderOverride: DataFolder);
agentDataFolderOverride: DataFolder,
sectionFolderOverrides: sectionFolderOverrides,
devicePreparationProbe: devicePreparationProbe);
}
public void Dispose()
@@ -287,6 +294,185 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/"));
}
// ===================================================== built-in catalog gates ====
// The RealmJoin sections ride behind the tenant's RealmJoin Watcher toggle, the
// Device Preparation bootstrapper event log behind the deterministic WDP marker.
// Every conditional section is skipped WITH a manifest line, never silently.
/// <summary>RealmJoin system-side folders redirected to temp (user-side goes via UserProfileResolver).</summary>
private sealed class RealmJoinRig : System.IDisposable
{
public TempDirectory WindowsLogs { get; } = new TempDirectory();
public TempDirectory Choco { get; } = new TempDirectory();
public TempDirectory UserProfile { get; } = new TempDirectory();
public string PackagesFolder => Path.Combine(WindowsLogs.Path, "RealmJoin");
public RealmJoinRig()
{
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin.log"), "rj");
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin2.log"), "rj2");
var pkg = Path.Combine(PackagesFolder, "Packages", "generic-7zip");
Directory.CreateDirectory(pkg);
File.WriteAllText(Path.Combine(pkg, "install.log"), "pkg");
var choco = Path.Combine(Choco.Path, "generic-7zip");
Directory.CreateDirectory(choco);
File.WriteAllText(Path.Combine(choco, "2026-06-30_install.log"), "choco");
var userRj = Path.Combine(UserProfile.Path, "AppData", "Local", "RealmJoin");
Directory.CreateDirectory(Path.Combine(userRj, "Logs", "generic-office"));
File.WriteAllText(Path.Combine(userRj, "tray.log"), "tray");
File.WriteAllText(Path.Combine(userRj, "config.pjson"), "not a log");
File.WriteAllText(Path.Combine(userRj, "Logs", "RjImeHost.log"), "host");
File.WriteAllText(Path.Combine(userRj, "Logs", "generic-office", "usersettings.log"), "pkg-user");
}
public IReadOnlyDictionary<string, string> Overrides => new Dictionary<string, string>
{
["RealmJoinWindows"] = WindowsLogs.Path,
["RealmJoinPackages"] = PackagesFolder,
["RealmJoinChoco"] = Choco.Path,
};
public void Dispose()
{
WindowsLogs.Dispose();
Choco.Dispose();
UserProfile.Dispose();
}
}
[Fact]
public void BuildArchiveBytes_skips_realmjoin_sections_when_watcher_disabled()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
try
{
var bytes = rig.Build(sectionFolderOverrides: rj.Overrides).BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("SCENARIO: devicePreparation=False realmJoinWatcher=False", manifest);
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinWindows", manifest);
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinUserLogs", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_includes_realmjoin_sections_when_watcher_enabled()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
try
{
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
.BuildArchiveBytes(enrollmentSucceeded: false);
var entries = ZipEntryNames(bytes);
// ZIP layout mirrors the disk layout: flat client logs + package tree under
// Windows/, tray logs + per-user Logs tree under User/.
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
Assert.Contains("RealmJoinLogs/Windows/realmjoin2.log", entries);
Assert.Contains("RealmJoinLogs/Windows/RealmJoin/Packages/generic-7zip/install.log", entries);
Assert.Contains("RealmJoinLogs/Choco/generic-7zip/2026-06-30_install.log", entries);
Assert.Contains("RealmJoinLogs/User/tray.log", entries);
Assert.Contains("RealmJoinLogs/User/Logs/RjImeHost.log", entries);
Assert.Contains("RealmJoinLogs/User/Logs/generic-office/usersettings.log", entries);
// Only tray*.log is collected from the user RealmJoin root — config stays out.
Assert.DoesNotContain(entries, e => e.EndsWith("config.pjson"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("BUILT-IN: RealmJoinUserLogs -> folder=", manifest);
Assert.DoesNotContain("BUILT-IN SKIPPED (RealmJoin Watcher disabled)", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_skips_realmjoin_user_sections_without_user_session()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
// Token present, no interactive user detected → user-profile sections skip with a
// manifest line; the system-side sections are unaffected.
UserProfileResolver.SetForTesting(null!);
try
{
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
.BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/User"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserTray", manifest);
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserLogs", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_collects_bootstrapper_event_log_only_on_device_preparation()
{
using var rig = new Rig();
using var winevt = new TempDirectory();
// Synthetic evtx: no registered channel claims the file, so the export falls
// back to a raw copy — the point here is the scenario gate, not wevtutil.
File.WriteAllBytes(Path.Combine(winevt.Path, "BootstrapperAgentServiceLogProvider.evtx"), new byte[] { 1, 2, 3 });
var overrides = new Dictionary<string, string> { ["ImeBootstrapperEventLog"] = winevt.Path };
var classic = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => false)
.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.DoesNotContain("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(classic));
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog",
ReadZipEntryText(classic, "package-manifest.txt"));
var wdp = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => true)
.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.Contains("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(wdp));
Assert.Contains("SCENARIO: devicePreparation=True", ReadZipEntryText(wdp, "package-manifest.txt"));
}
[Fact]
public void BuildArchiveBytes_treats_failing_device_preparation_probe_as_classic()
{
using var rig = new Rig();
var bytes = rig.Build(devicePreparationProbe: () => throw new System.InvalidOperationException("registry boom"))
.BuildArchiveBytes(enrollmentSucceeded: true);
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("SCENARIO PROBE FAILED (treated as Classic): registry boom", manifest);
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog", manifest);
Assert.Contains("sessioninfo.txt", ZipEntryNames(bytes)); // archive still built
}
[Theory]
[InlineData(DiagnosticsSectionCondition.Always, false, false, true)]
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, false, true, false)]
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, true, false, true)]
[InlineData(DiagnosticsSectionCondition.DevicePreparation, true, false, false)]
[InlineData(DiagnosticsSectionCondition.DevicePreparation, false, true, true)]
[InlineData((DiagnosticsSectionCondition)999, true, true, false)]
public void IsSectionActive_gates_on_condition(DiagnosticsSectionCondition condition, bool watcher, bool wdp, bool expected)
{
var section = new DiagnosticsBuiltInSection("X", "X", @"C:\X", new[] { "*.log" }, false, "x", condition);
var cfg = Cfg();
cfg.EnableRealmJoinWatcher = watcher;
Assert.Equal(expected, DiagnosticsPackageService.IsSectionActive(section, cfg, wdp));
}
private static string[] ZipEntryNames(byte[] zipBytes)
{
using var ms = new MemoryStream(zipBytes);
@@ -341,7 +527,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
// there is no known success to skip on. Reaching the archive build (sentinel
// throw → error result) proves the gate was passed; a skip would return null.
// throw → error result) proves the gate was passed; a skip would return Skipped.
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
@@ -357,7 +543,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: true);
Assert.Null(result);
Assert.NotNull(result);
Assert.True(result!.Skipped);
Assert.False(result.Success);
Assert.Equal(DiagnosticsUploadResult.SkipOnFailureSucceeded, result.ErrorCode);
}
[Fact]
@@ -367,7 +556,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
var result = await BuildGateProbe(rig.Logger, "Off")
.CreateAndUploadAsync(enrollmentSucceeded: null);
Assert.Null(result);
// The gate name travels in ErrorCode so the dispatcher's server_action_failed
// event says "mode_off" instead of a generic "diagnostics_upload_failed".
Assert.NotNull(result);
Assert.True(result!.Skipped);
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, result.ErrorCode);
}
[Theory]
@@ -122,6 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.Equal("config_fetch_failed", (string)typed["failureReason"]);
}
[Fact]
public async Task Dispatch_request_diagnostics_skipped_by_gate_surfaces_gate_name_as_failure_reason()
{
// Session 2550bcea (2026-08-29): the on-demand collection was refused by the
// mode=Off gate and the portal only saw a generic "diagnostics_upload_failed".
// The gate name must travel through to the event so the operator can tell a
// config gate from a real upload error.
using var rig = new Rig();
rig.DiagnosticsResult = DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
await rig.Build().DispatchAsync(new List<ServerAction>
{
new ServerAction { Type = ServerActionTypes.RequestDiagnostics, QueuedAt = DateTime.UtcNow },
});
Assert.Equal(1, rig.DiagnosticsCalls);
Assert.Empty(rig.EventsOfType("server_action_executed"));
var failed = rig.EventsOfType("server_action_failed").Single();
var typed = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(failed.TypedPayload!);
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, (string)typed["failureReason"]);
}
[Fact]
public async Task Dispatch_unknown_action_type_emits_failed_with_unknown_action_type_reason()
{
@@ -0,0 +1,429 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// SystemTimelineTracker — clock steps (Kernel-General 1) and completed sleep episodes
/// (Power-Troubleshooter 1 / Kernel-Power 507) from the System channel. Drives the primitive
/// <see cref="SystemTimelineTracker.ProcessEvent"/> test seam (no real EventRecord, which is
/// abstract + Windows-only), mirroring the WindowsUpdateTracker test pattern.
/// </summary>
public sealed class SystemTimelineTrackerTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink;
private readonly SystemTimelineTracker _tracker;
private long _nextRecordId = 1;
public SystemTimelineTrackerTests()
{
_sink = new FakeSignalIngressSink();
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_tracker = new SystemTimelineTracker(
sessionId: "sess-stl",
tenantId: "tenant-stl",
post: post,
logger: logger,
backfillEnabled: false,
stateDirectory: null); // in-memory dedup for most tests
}
public void Dispose() => _tmp.Dispose();
// -------------------------------------------------------------------
// Emission helpers
// -------------------------------------------------------------------
private void EmitClockChange(
DateTime? oldTime, DateTime? newTime, string? rawDeltaMs = null,
int reason = 1, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (oldTime.HasValue) data["OldTime"] = oldTime.Value.ToString("o");
if (newTime.HasValue) data["NewTime"] = newTime.Value.ToString("o");
if (rawDeltaMs != null) data["TimeDeltaInMs"] = rawDeltaMs;
data["Reason"] = reason.ToString();
data["ProcessName"] = @"\Device\HarddiskVolume3\Windows\System32\svchost.exe";
data["ProcessID"] = "6892";
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderKernelGeneral,
eventId: SystemTimelineTracker.EventId_ClockChange,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
eventData: data,
isBackfill: isBackfill);
}
private void EmitClassicResume(
DateTime sleepTime, DateTime wakeTime, string? effectiveState = "1",
string? wakeSourceText = null, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["SleepTime"] = sleepTime.ToString("o"),
["WakeTime"] = wakeTime.ToString("o"),
["WakeSourceType"] = "0",
};
if (effectiveState != null) data["EffectiveState"] = effectiveState;
if (wakeSourceText != null) data["WakeSourceText"] = wakeSourceText;
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderPowerTroubleshooter,
eventId: SystemTimelineTracker.EventId_ClassicResume,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? wakeTime,
eventData: data,
isBackfill: isBackfill);
}
private void EmitModernStandbyExit(
long sleepDurationUs, long durationUs, string powerStateAc = "true",
long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["SleepDurationInUs"] = sleepDurationUs.ToString(),
["DurationInUs"] = durationUs.ToString(),
["Reason"] = "32",
["PowerStateAc"] = powerStateAc,
["BatteryRemainingCapacityOnExit"] = "34180",
};
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderKernelPower,
eventId: SystemTimelineTracker.EventId_ModernStandbyExit,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
eventData: data,
isBackfill: isBackfill);
}
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
// -------------------------------------------------------------------
// system_clock_changed
// -------------------------------------------------------------------
[Fact]
public void ClockStep_AboveThreshold_EmitsInfo_WithSignedDeltaAndNormalizedTimes()
{
var oldTime = At;
var newTime = At.AddSeconds(10);
EmitClockChange(oldTime, newTime, reason: 1);
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
var data = Data(s);
Assert.Equal(10_000L, data["timeDeltaMs"]);
Assert.Equal("application_set", data["reasonText"]);
Assert.Equal(1, data["reason"]);
Assert.Equal(oldTime.ToString("o"), data["oldTime"]);
Assert.Equal(newTime.ToString("o"), data["newTime"]);
Assert.Equal(6892, data["processId"]);
Assert.Contains("forward", (string)s.Payload![SignalPayloadKeys.Message]);
Assert.Contains("svchost.exe", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Theory]
[InlineData(1)]
[InlineData(1999)]
public void ClockMicroStep_BelowThreshold_Suppressed(int deltaMs)
{
EmitClockChange(At, At.AddMilliseconds(deltaMs));
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void ClockStep_Backward_EmitsNegativeDelta()
{
EmitClockChange(oldTime: At, newTime: At.AddSeconds(-3));
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal(-3000L, Data(s)["timeDeltaMs"]);
Assert.Contains("backward", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ClockStep_AtLeastFiveMinutes_WarningAndImmediateUpload()
{
EmitClockChange(At, At.AddMinutes(5), reason: 2);
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("hardware_clock_sync", Data(s)["reasonText"]);
}
[Fact]
public void ClockStep_NoUsableTimesOrDelta_Skipped()
{
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "not-a-number");
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void ClockStep_FallsBackToTimeDeltaInMs_WhenTimesUnparseable()
{
// Only the raw delta parses — the direction information of NewTime/OldTime is gone,
// but the magnitude still gates and emits.
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "7200000");
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal(7_200_000L, Data(s)["timeDeltaMs"]);
}
// -------------------------------------------------------------------
// system_sleep_episode — classic (Power-Troubleshooter 1)
// -------------------------------------------------------------------
[Fact]
public void ClassicResume_EffectiveState5_EmitsHibernateEpisode()
{
var sleepAt = At.AddHours(-2);
EmitClassicResume(sleepAt, At, effectiveState: "5", wakeSourceText: "Power Button");
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
var data = Data(s);
Assert.Equal("hibernate", data["kind"]);
Assert.Equal(7200L, data["durationSeconds"]);
Assert.Equal(sleepAt.ToString("o"), data["enteredAt"]);
Assert.Equal(At.ToString("o"), data["exitedAt"]);
Assert.Equal("Power Button", data["wakeSourceText"]);
Assert.Contains("hibernate", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ClassicResume_NonHibernateState_EmitsSleepKind()
{
EmitClassicResume(At.AddMinutes(-30), At, effectiveState: "4");
Assert.Equal("sleep", Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)))["kind"]);
}
[Fact]
public void ClassicResume_ShortEpisode_StillEmits()
{
// No duration floor for classic sleep: the event only exists for genuinely completed
// S3/S4 transitions, so a 30 s real sleep is still signal.
EmitClassicResume(At.AddSeconds(-30), At);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal(30L, Data(s)["durationSeconds"]);
}
// -------------------------------------------------------------------
// system_sleep_episode — Modern Standby (Kernel-Power 507)
// -------------------------------------------------------------------
[Fact]
public void ModernStandbyExit_EmitsEpisode_WithDerivedEnterTime()
{
// Values from a live 507 on this dev machine: 38 min scenario, ~38 min actual sleep.
EmitModernStandbyExit(sleepDurationUs: 2_324_095_807, durationUs: 2_330_201_652);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
var data = Data(s);
Assert.Equal("modern_standby", data["kind"]);
Assert.Equal(2330L, data["durationSeconds"]);
Assert.Equal(2324L, data["sleepDurationSeconds"]);
Assert.Equal(true, data["onAcPower"]);
Assert.Equal(34180L, data["batteryRemainingCapacityOnExit"]);
Assert.Equal(At.ToString("o"), data["exitedAt"]);
Assert.Equal(At.AddMilliseconds(-2_330_201.652).ToString("o"), data["enteredAt"]);
Assert.Contains("Modern Standby", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ModernStandbyExit_SleepBelowFloor_Suppressed()
{
EmitModernStandbyExit(sleepDurationUs: 30_000_000, durationUs: 90_000_000); // 30 s sleep
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
}
[Fact]
public void ModernStandbyExit_ScreenOffWithoutSleep_Suppressed()
{
// SleepEntered=false scenarios carry SleepDurationInUs 0 — the single duration gate
// suppresses them without a separate branch.
EmitModernStandbyExit(sleepDurationUs: 0, durationUs: 6_106_696);
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
}
[Fact]
public void ModernStandbyExit_ScenarioShorterThanSleep_ClampsToSleepDuration()
{
EmitModernStandbyExit(sleepDurationUs: 120_000_000, durationUs: 0);
var data = Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)));
Assert.Equal(120L, data["durationSeconds"]);
Assert.Equal(120L, data["sleepDurationSeconds"]);
}
// -------------------------------------------------------------------
// Backfill semantics
// -------------------------------------------------------------------
[Fact]
public void BackfilledEvent_UsesEventTimeForTimelineTimestamp_AndFlagsData()
{
// A pre-agent standby episode (record created before the agent started) must land on
// the timeline where the device actually woke, not at agent start.
var wakeTime = At.AddHours(-3);
EmitClassicResume(wakeTime.AddHours(-1), wakeTime, isBackfill: true, timeCreatedUtc: wakeTime);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal(wakeTime, s.OccurredAtUtc);
Assert.Equal(true, Data(s)["backfilled"]);
}
// -------------------------------------------------------------------
// Dedup / watermark
// -------------------------------------------------------------------
[Fact]
public void SameRecordId_ProcessedTwice_EmitsOnce()
{
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void BackfillEventWithLowerRecordId_AfterLiveEvent_IsStillEmitted()
{
// The live watcher is armed BEFORE backfill runs, so a live record with a higher
// RecordId can be processed first. An older, never-emitted backfill record must NOT be
// suppressed — pre-agent episodes are the whole point (high-water-mark trap).
EmitClockChange(At, At.AddMinutes(1), recordId: 100); // live, high
EmitClockChange(At, At.AddMinutes(2), recordId: 50, isBackfill: true); // backfill, older
Assert.Equal(2, ByType(Constants.EventTypes.SystemClockChanged).Count);
}
[Fact]
public void Watermark_PersistsAcrossTrackerInstances()
{
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var clockData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["OldTime"] = At.ToString("o"),
["NewTime"] = At.AddMinutes(10).ToString("o"),
["Reason"] = "1",
};
var first = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
first.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: false);
// A fresh tracker (agent restart) must load the watermark and skip the already-emitted
// record when the backfill re-reads it.
var second = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: true);
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void UnknownProviderOrEventId_Ignored()
{
_tracker.ProcessEvent("Microsoft-Windows-Kernel-Power", eventId: 566, recordId: _nextRecordId++,
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
_tracker.ProcessEvent("Some-Other-Provider", eventId: 1, recordId: _nextRecordId++,
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
Assert.Empty(_sink.Posted);
}
// -------------------------------------------------------------------
// XPath + parsing
// -------------------------------------------------------------------
[Fact]
public void BuildXPath_ContainsAllThreeProviderIdPairs_AndNo506()
{
var xpath = SystemTimelineTracker.BuildXPath();
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-General'] and EventID=1", xpath);
Assert.Contains("Provider[@Name='Microsoft-Windows-Power-Troubleshooter'] and EventID=1", xpath);
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-Power'] and EventID=507", xpath);
Assert.DoesNotContain("506", xpath);
}
[Fact]
public void BuildBackfillXPath_AppendsTimediffClause()
{
var xpath = SystemTimelineTracker.BuildBackfillXPath(86_400_000);
Assert.Contains("TimeCreated[timediff(@SystemTime) <= 86400000]", xpath);
Assert.Contains("EventID=507", xpath);
}
[Fact]
public void ParseEventData_RealKernelGeneralPayload_YieldsAuthoritativeFields()
{
// Verbatim (values shortened) from a live Kernel-General 1 record on a 26220 dev machine.
const string xml =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
"<System><Provider Name='Microsoft-Windows-Kernel-General'/><EventID>1</EventID></System>" +
"<EventData>" +
"<Data Name='NewTime'>2026-08-21T09:40:02.1987028Z</Data>" +
"<Data Name='OldTime'>2026-08-21T09:40:02.1973028Z</Data>" +
"<Data Name='TimeDeltaInMs'>1</Data>" +
"<Data Name='Reason'>1</Data>" +
"<Data Name='ProcessName'>\\Device\\HarddiskVolume3\\Windows\\System32\\svchost.exe</Data>" +
"<Data Name='ProcessID'>6892</Data>" +
"</EventData></Event>";
var data = SystemTimelineTracker.ParseEventData(xml);
Assert.Equal("1", data["TimeDeltaInMs"]);
Assert.Equal("2026-08-21T09:40:02.1987028Z", data["newtime"]); // case-insensitive keys
Assert.NotNull(SystemTimelineTracker.TryParseUtc(data["OldTime"]));
}
[Fact]
public void FormatDuration_CompactForms()
{
Assert.Equal("2h 05m", SystemTimelineTracker.FormatDuration(TimeSpan.FromMinutes(125)));
Assert.Equal("56m 12s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(56 * 60 + 12)));
Assert.Equal("45s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(45)));
}
[Fact]
public void ProcessLeafName_StripsDevicePath()
{
Assert.Equal("svchost.exe",
SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\Windows\System32\svchost.exe"));
Assert.Null(SystemTimelineTracker.ProcessLeafName(null));
Assert.Null(SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\"));
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
{
public class DoGroupIdServiceTests
{
// -------------------------------------------------------------------
// ComputeGroupId — RealmJoin byte-layout compatibility
// -------------------------------------------------------------------
[Fact]
public void ComputeGroupId_MatchesHandComputedVector()
{
// Layout: {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac — through new Guid(byte[])
// the first three groups print little-endian, the last eight bytes verbatim.
var guid = DoGroupIdService.ComputeGroupId(
new byte[] { 192, 168, 178, 1 },
new byte[] { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55 });
Assert.Equal("00063065-0000-00a8-b201-001122334455", guid.ToString());
}
[Theory]
[InlineData(new byte[] { 10, 0, 0, 1 }, new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF })]
[InlineData(new byte[] { 192, 168, 1, 254 }, new byte[] { 0x00, 0x50, 0x56, 0x01, 0x02, 0x03 })]
[InlineData(new byte[] { 172, 16, 254, 3 }, new byte[] { 0xF0, 0x9F, 0xC2, 0x00, 0x00, 0x01 })]
public void ComputeGroupId_MatchesRealmJoinReferenceImplementation(byte[] ip, byte[] mac)
{
Assert.Equal(RealmJoinReferenceGuid(ip, mac), DoGroupIdService.ComputeGroupId(ip, mac));
}
/// <summary>Verbatim port of RealmJoin's ConvertToGuid — the compatibility contract.</summary>
private static Guid RealmJoinReferenceGuid(byte[] ipBytes, byte[] address)
{
byte[] prefix = { 101, 48, 6 };
var stuff = new byte[4];
stuff[0] = ipBytes[1];
stuff[2] = ipBytes[2];
stuff[3] = ipBytes[3];
return new Guid(prefix
.Concat(new byte[16 - prefix.Length - stuff.Length - address.Length])
.Concat(stuff)
.Concat(address)
.ToArray());
}
[Fact]
public void ComputeGroupId_RejectsNonIpv4AndBadMacLengths()
{
var ip4 = new byte[] { 10, 0, 0, 1 };
var mac = new byte[] { 1, 2, 3, 4, 5, 6 };
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(new byte[16], mac));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(null!, mac));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, new byte[8]));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, null!));
}
[Fact]
public void ComputeGroupId_RegistryFormat_IsBracelessLowercase()
{
// RealmJoin writes SetValue(name, Guid) which stringifies via Guid.ToString() ("d"
// format) — our written value must be the identical string form.
var text = DoGroupIdService.ComputeGroupId(
new byte[] { 10, 1, 2, 3 }, new byte[] { 0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03 }).ToString();
Assert.DoesNotContain("{", text);
Assert.Equal(text.ToLowerInvariant(), text);
Assert.Equal(36, text.Length);
}
// -------------------------------------------------------------------
// DecideAction — ownership matrix
// -------------------------------------------------------------------
private const string Computed = "00063065-0000-00a8-b201-001122334455";
private const string Other = "11111111-2222-3333-4444-555555555555";
[Theory]
[InlineData(null)]
[InlineData("")]
public void DecideAction_NoExistingValue_Writes(string? existing)
{
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(existing, Computed, null));
}
[Fact]
public void DecideAction_ExistingEqualsComputed_IsNoOp()
{
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(Computed, Computed, null));
}
[Fact]
public void DecideAction_ExistingEqualsComputed_ToleratesBracesAndCase()
{
// A value written by another tool in "{...}"/uppercase form is still semantically ours.
var braced = "{" + Computed.ToUpperInvariant() + "}";
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(braced, Computed, null));
}
[Fact]
public void DecideAction_ExistingIsOurStaleValue_WritesUpdate()
{
// Gateway changed: the registry still holds the GUID we wrote for the old network.
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(Other, Computed, Other));
}
[Fact]
public void DecideAction_ForeignValue_Skips()
{
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, null));
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, Computed));
}
[Fact]
public void DecideAction_NonGuidGarbageExisting_SkipsAsForeign()
{
// Never overwrite a value we can't even parse — conservative by design.
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction("not-a-guid", Computed, null));
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.DecisionCore.Signals;
@@ -11,8 +12,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
internal sealed class FakeSignalIngressSink : ISignalIngressSink
{
private readonly List<PostedSignal> _posted = new List<PostedSignal>();
private readonly object _gate = new object();
public IReadOnlyList<PostedSignal> Posted => _posted;
/// <summary>
/// Snapshot, not the live list: producers post from timer / worker threads (e.g. the
/// parked-dwell tripwire) while tests poll this in a SpinWait loop. Enumerating the live
/// list across an Add throws "Collection was modified" — a rare CI-only failure
/// (run 32644425162) with no assertion text.
/// </summary>
public IReadOnlyList<PostedSignal> Posted
{
get { lock (_gate) return _posted.ToArray(); }
}
/// <summary>When non-null, <see cref="Post"/> throws instead of capturing.</summary>
public Exception? ThrowOnPost { get; set; }
@@ -27,7 +38,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
object? typedPayload = null)
{
if (ThrowOnPost != null) throw ThrowOnPost;
_posted.Add(new PostedSignal(kind, occurredAtUtc, sourceOrigin, evidence, payload, kindSchemaVersion, typedPayload));
lock (_gate)
_posted.Add(new PostedSignal(kind, occurredAtUtc, sourceOrigin, evidence, payload, kindSchemaVersion, typedPayload));
}
internal sealed class PostedSignal
@@ -498,6 +498,207 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.Equal(5, observedCount);
}
// ============================================================ Worker re-entrancy (2026-08-29)
[Fact]
public void Worker_thread_post_with_full_channel_does_not_deadlock_and_is_processed_next()
{
// Channel capacity 1, the worker is parked inside ApplyStep on a gate while a
// second producer keeps the channel full. When the gate opens, ApplyStep posts a
// verdict from the worker thread. Before the fix, Post would block in
// BlockingCollection.Add forever (the worker is the only consumer). After the fix,
// the verdict goes to the worker-local queue, is processed BEFORE the queued channel
// item, and the worker keeps draining.
using var tmp = new TempDirectory();
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
SignalIngress? ingRef = null;
var gate = new ManualResetEventSlim(false);
var inApply = new ManualResetEventSlim(false);
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
var ing = new SignalIngress(
engine: new DecisionEngine(),
signalLog: log,
traceCounter: new SessionTraceOrdinalProvider(),
processor: processor,
clock: new VirtualClock(At),
channelCapacity: 1);
ingRef = ing;
ing.Start();
// Item 0 — worker takes it and parks inside ApplyStep on the gate.
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
Assert.True(inApply.Wait(5000));
// Item 1 fills the single slot; item 2 blocks a foreign producer (channel full).
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
var blockedProducer = Task.Run(() =>
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(2), "Collector", RawEvidence("s2")));
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1));
Assert.False(blockedProducer.Wait(200)); // genuinely blocked by back-pressure
// Release the worker: ApplyStep(s0) now posts the verdict from the worker thread
// into a full channel.
gate.Set();
Assert.True(blockedProducer.Wait(5000), "foreign producer must be released — worker must keep draining");
Assert.True(WaitFor(() => processor.CallCount == 4));
ing.Stop();
Assert.Equal(
new[]
{
DecisionSignalKind.SessionStarted,
DecisionSignalKind.ClassifierVerdictIssued, // follow-up drained BEFORE the next channel item
DecisionSignalKind.DesktopArrived,
DecisionSignalKind.DesktopArrived,
},
processor.Kinds);
Assert.Equal(1, ing.ReentrantPostCount);
Assert.Equal(0, ing.PendingSignalCount);
// Same worker thread for every step — single-writer invariant intact.
Assert.Single(processor.ThreadIds.Distinct());
// Follow-up went through the normal path: ordinal assigned + durably logged.
var logged = log.ReadAll();
Assert.Equal(4, logged.Count);
Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, logged[1].Kind);
Assert.Equal(1, logged[1].SessionSignalOrdinal);
}
[Fact]
public void Worker_thread_post_chain_is_drained_iteratively_in_order()
{
// A follow-up whose own ApplyStep posts another follow-up: both are processed on
// the worker, in order, before the next channel item, without recursion.
using var tmp = new TempDirectory();
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
SignalIngress? ingRef = null;
var processor = new ChainingProcessor(() => ingRef!);
var ing = new SignalIngress(
engine: new DecisionEngine(),
signalLog: log,
traceCounter: new SessionTraceOrdinalProvider(),
processor: processor,
clock: new VirtualClock(At));
ingRef = ing;
ing.Start();
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
ing.Stop();
Assert.Equal(
new[] { "Collector", "chain:1", "chain:2", "Collector" },
processor.Origins);
Assert.Equal(2, ing.ReentrantPostCount);
Assert.Equal(4, log.ReadAll().Count);
}
[Fact]
public void Worker_thread_post_during_Stop_drain_is_still_processed()
{
// Stop() completes adding, but a verdict posted by the worker while it finishes
// the last channel item must not be dropped — it belongs to that step.
using var tmp = new TempDirectory();
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
SignalIngress? ingRef = null;
var gate = new ManualResetEventSlim(false);
var inApply = new ManualResetEventSlim(false);
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
var ing = new SignalIngress(
engine: new DecisionEngine(),
signalLog: log,
traceCounter: new SessionTraceOrdinalProvider(),
processor: processor,
clock: new VirtualClock(At));
ingRef = ing;
ing.Start();
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
Assert.True(inApply.Wait(5000));
var stopTask = Task.Run(() => ing.Stop());
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0 && !stopTask.IsCompleted));
gate.Set();
Assert.True(stopTask.Wait(5000));
Assert.Equal(
new[] { DecisionSignalKind.SessionStarted, DecisionSignalKind.ClassifierVerdictIssued },
processor.Kinds);
Assert.Equal(2, log.ReadAll().Count);
}
/// <summary>
/// Parks inside ApplyStep(SessionStarted) on a gate, then posts a verdict back into the
/// ingress from the worker thread — the EffectRunner/RunClassifier shape.
/// </summary>
private sealed class GatedReentrantProcessor : IDecisionStepProcessor
{
private readonly Func<ISignalIngressSink> _sink;
private readonly ManualResetEventSlim _gate;
private readonly ManualResetEventSlim _inApply;
private readonly List<(DecisionSignalKind Kind, int ThreadId)> _calls = new List<(DecisionSignalKind, int)>();
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
public GatedReentrantProcessor(Func<ISignalIngressSink> sink, ManualResetEventSlim gate, ManualResetEventSlim inApply)
{
_sink = sink;
_gate = gate;
_inApply = inApply;
}
public int CallCount { get { lock (_calls) { return _calls.Count; } } }
public DecisionSignalKind[] Kinds { get { lock (_calls) { return _calls.Select(c => c.Kind).ToArray(); } } }
public int[] ThreadIds { get { lock (_calls) { return _calls.Select(c => c.ThreadId).ToArray(); } } }
public DecisionCore.State.DecisionState CurrentState => _state;
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
{
lock (_calls) { _calls.Add((signal.Kind, Thread.CurrentThread.ManagedThreadId)); }
_state = step.NewState;
if (signal.Kind == DecisionSignalKind.SessionStarted)
{
_inApply.Set();
_gate.Wait();
_sink().Post(
DecisionSignalKind.ClassifierVerdictIssued,
signal.OccurredAtUtc,
"effectrunner:classifier:test",
new Evidence(EvidenceKind.Synthetic, "classifier:test:h", "Strong/90"),
new Dictionary<string, string> { ["level"] = "Strong" });
}
return EffectRunResult.Empty();
}
}
private sealed class ChainingProcessor : IDecisionStepProcessor
{
private readonly Func<ISignalIngressSink> _sink;
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
public List<string> Origins { get; } = new List<string>();
public ChainingProcessor(Func<ISignalIngressSink> sink) { _sink = sink; }
public DecisionCore.State.DecisionState CurrentState => _state;
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
{
Origins.Add(signal.SourceOrigin);
_state = step.NewState;
string? next = signal.Kind == DecisionSignalKind.SessionStarted ? "chain:1"
: signal.SourceOrigin == "chain:1" ? "chain:2"
: null;
if (next != null)
{
_sink().Post(
DecisionSignalKind.ClassifierVerdictIssued,
signal.OccurredAtUtc,
next,
new Evidence(EvidenceKind.Synthetic, next, next));
}
return EffectRunResult.Empty();
}
}
[Fact]
public void Post_via_ISignalIngressSink_reaches_reducer_like_any_signal()
{
@@ -0,0 +1,57 @@
#nullable enable
using System;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// SystemTimelineWatcherHost — thin ICollectorHost wrapper over SystemTimelineTracker.
/// Real event-log arming stays untested by convention (ConsoleBypassWatcher precedent);
/// these tests pin the lifecycle contract only.
/// </summary>
public sealed class SystemTimelineWatcherHostTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private SystemTimelineWatcherHost CreateHost() => new SystemTimelineWatcherHost(
sessionId: "sess-stl",
tenantId: "tenant-stl",
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
ingress: new FakeSignalIngressSink(),
clock: new VirtualClock(new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc)),
backfillLookbackMinutes: 0,
stateDirectory: _tmp.Path);
[Fact]
public void Stop_BeforeStart_DoesNotThrow()
{
using (var host = CreateHost())
{
host.Stop();
}
}
[Fact]
public void Dispose_Twice_DoesNotThrow()
{
var host = CreateHost();
host.Dispose();
host.Dispose();
}
[Fact]
public void Name_IsStable()
{
using (var host = CreateHost())
{
Assert.Equal("SystemTimelineTracker", host.Name);
}
}
}
}
@@ -58,6 +58,47 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Same(registration, result.Registration);
}
[Fact]
public void RotateSession_rotates_id_updates_config_drops_spool_and_keeps_whiteglove_marker()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp.Path);
var persistence = new SessionIdPersistence(tmp.Path);
var original = persistence.GetOrCreate();
persistence.SaveWhiteGloveComplete();
var config = new AgentConfiguration { ApiBaseUrl = "https://example.invalid", TenantId = "t", SessionId = original };
var transportDir = Path.Combine(tmp.Path, "Spool");
Directory.CreateDirectory(transportDir);
File.WriteAllText(Path.Combine(transportDir, "spool.jsonl"), "{}");
File.WriteAllText(Path.Combine(transportDir, "upload-cursor.json"), "{}");
File.WriteAllText(Path.Combine(transportDir, "unrelated.txt"), "keep");
var rotated = BackendSessionRegistration.RotateSession(config, persistence, auth: null, transportDir, logger);
Assert.NotEqual(original, rotated);
Assert.Equal(rotated, config.SessionId);
Assert.Equal(rotated, new SessionIdPersistence(tmp.Path).GetOrCreate());
Assert.True(persistence.IsWhiteGloveResume());
Assert.False(File.Exists(Path.Combine(transportDir, "spool.jsonl")));
Assert.False(File.Exists(Path.Combine(transportDir, "upload-cursor.json")));
Assert.True(File.Exists(Path.Combine(transportDir, "unrelated.txt")));
}
[Fact]
public void RotateSession_tolerates_a_missing_transport_directory()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp.Path);
var persistence = new SessionIdPersistence(tmp.Path);
var config = new AgentConfiguration { ApiBaseUrl = "https://example.invalid", TenantId = "t", SessionId = persistence.GetOrCreate() };
var rotated = BackendSessionRegistration.RotateSession(
config, persistence, auth: null, Path.Combine(tmp.Path, "does-not-exist"), logger);
Assert.Equal(rotated, config.SessionId);
}
[Fact]
public void Register_throws_on_null_arguments()
{
@@ -182,6 +182,73 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("FromCache", data["outcome"]);
}
// ── Post-registration recovery (Phase 6.5, tenant aebdce78 audit 2026-08-24) ─
[Fact]
public void EmitFetchFailedIfAny_AfterRecovery_MarksRecoveredAndDemotesToInfo()
{
using var rig = new EmitterRig();
// Startup fetch failed (network link not up yet), then the post-registration
// recovery fetched ConfigVersion 39. The event must keep the original failure
// details AND mark the heal.
rig.RemoteConfig.SetFakeOutcome(
RemoteConfigFetchOutcome.UsedDefaults,
configVersion: 39, // CurrentConfig now holds the recovered config
attempts: 4,
failureType: "HttpRequestException",
failureMessage: "simulated dead network link",
recovered: true);
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
Assert.Equal("UsedDefaults", data["outcome"]); // startup failure record intact
Assert.Equal(4, data["attempts"]);
Assert.Equal(true, data["recovered"]);
Assert.Equal(39, data["recoveredConfigVersion"]);
Assert.Equal(nameof(EventSeverity.Info), rig.Sink.Posted[0].Payload!["severity"]);
}
[Fact]
public void EmitFetchFailedIfAny_WithoutRecovery_StaysWarning()
{
using var rig = new EmitterRig();
rig.RemoteConfig.SetFakeOutcome(
RemoteConfigFetchOutcome.UsedDefaults,
configVersion: 0,
attempts: 4,
failureType: "HttpRequestException",
failureMessage: "still down");
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
Assert.False(data.ContainsKey("recovered"));
Assert.False(data.ContainsKey("recoveredConfigVersion"));
Assert.Equal(nameof(EventSeverity.Warning), rig.Sink.Posted[0].Payload!["severity"]);
}
[Fact]
public void EmitAgentStarted_AfterRecovery_FlagsLiveConfig()
{
using var rig = new EmitterRig();
rig.RemoteConfig.SetFakeOutcome(
RemoteConfigFetchOutcome.UsedDefaults,
configVersion: 39,
recovered: true);
LifecycleEmitters.EmitAgentStarted(
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
Assert.Equal(39, data["configVersion"]);
Assert.Equal(true, data["remoteConfigFetched"]); // running on a live-fetched config
Assert.Equal("UsedDefaults", data["remoteConfigOutcome"]); // startup outcome stays honest
Assert.Equal(true, data["remoteConfigRecovered"]);
}
// ── Helpers ─────────────────────────────────────────────────────────
private static Dictionary<string, object> AssertSingleEmit(
@@ -258,8 +325,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
int attempts = 1,
string? failureType = null,
string? failureMessage = null,
int? authStatusCode = null)
int? authStatusCode = null,
bool recovered = false)
{
typeof(RemoteConfigService).GetProperty(nameof(RecoveredAfterFetchFailure))?.SetValue(this, recovered);
// CurrentConfig is set via the protected SetConfig path — easiest:
// a real FetchConfigAsync replacement that just stashes a canned config.
typeof(RemoteConfigService).GetMethod("SetConfig",
@@ -339,6 +339,114 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.False(StartupEnvironmentProbes.IsNtpWithinTolerance(null!));
}
// ================================================================= DO GroupId auto-set
// Builder/fingerprint tests only — TrySetGroupId itself does live registry + ARP I/O and
// must never run in a unit test (it would write HKLM on the build machine).
[Fact]
public void BuildDoGroupIdEvent_written_success_is_info_with_full_data()
{
var result = new DoGroupIdSetResult
{
Success = true,
Written = true,
GroupId = "00063065-0000-00a8-b201-001122334455",
PreviousValue = null,
GatewayIp = "192.168.178.1",
GatewayMac = "00-11-22-33-44-55",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal("do_group_id_auto_set", evt.EventType);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Equal("StartupEnvironmentProbes", evt.Source);
Assert.Equal("S1", evt.SessionId);
Assert.Equal("T1", evt.TenantId);
Assert.Contains("00063065-0000-00a8-b201-001122334455", evt.Message);
Assert.Equal("192.168.178.1", evt.Data["gatewayIp"]);
Assert.Equal("00-11-22-33-44-55", evt.Data["gatewayMac"]);
Assert.Equal(true, evt.Data["written"]);
Assert.Equal(true, evt.Data["success"]);
Assert.Equal(false, evt.Data["skipped"]);
}
[Fact]
public void BuildDoGroupIdEvent_noop_success_says_already_set()
{
var result = new DoGroupIdSetResult
{
Success = true,
Written = false,
GroupId = "00063065-0000-00a8-b201-001122334455",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("already set", evt.Message);
Assert.Equal(false, evt.Data["written"]);
}
[Fact]
public void BuildDoGroupIdEvent_skip_is_info_with_reason()
{
var result = new DoGroupIdSetResult
{
Skipped = true,
SkipReason = "foreign_value_present",
PreviousValue = "11111111-2222-3333-4444-555555555555",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("foreign_value_present", evt.Message);
Assert.Equal("foreign_value_present", evt.Data["skipReason"]);
Assert.Equal("11111111-2222-3333-4444-555555555555", evt.Data["previousValue"]);
}
[Fact]
public void BuildDoGroupIdEvent_error_is_warning()
{
var result = new DoGroupIdSetResult { Error = "readback mismatch" };
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Contains("readback mismatch", evt.Message);
Assert.Equal("readback mismatch", evt.Data["error"]);
}
[Fact]
public void DoGroupIdFingerprint_roundtrips_ownership_marker_for_success()
{
var fp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
{
Success = true,
GroupId = "00063065-0000-00a8-b201-001122334455",
});
Assert.Equal("written:00063065-0000-00a8-b201-001122334455", fp);
Assert.Equal("00063065-0000-00a8-b201-001122334455", StartupEnvironmentProbes.ExtractLastWrittenGroupId(fp));
}
[Fact]
public void DoGroupIdFingerprint_skip_carries_no_ownership_and_errors_never_dedupe()
{
var skipFp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
{
Skipped = true,
SkipReason = "mdm_policy_present",
});
Assert.Equal("skipped:mdm_policy_present", skipFp);
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(skipFp));
// Errors: no fingerprint → the probe re-runs AND re-emits each failing start.
Assert.Null(StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult { Error = "boom" }));
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(null));
}
[Fact]
public async Task RunAsync_skips_geo_timezone_and_ntp_once_all_succeeded_only_power_state_emits()
{
@@ -0,0 +1,146 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Pins <see cref="EntraDeviceCertHelper.ParseGuidFromExtension"/>, the pure byte-level part of the
/// cert probe. Both encodings Microsoft actually ships are covered: the ASN.1 OCTET STRING wrapper
/// used by the MS-Organization-Access TenantId/DeviceId extensions (and Intune's
/// <c>1.2.840.113556.5.6</c> / <c>5.14</c>), and the BARE 16-byte payload used by Intune's DeviceId
/// extension <c>1.2.840.113556.5.4</c> — the latter was silently returning <c>null</c> before.
/// The store-iteration shell around it needs a real machine cert store and is not unit-tested.
/// </summary>
public sealed class EntraDeviceCertHelperParseTests
{
// Synthetic — never a real tenant/device id. ToByteArray() gives the MS little-endian layout
// that both cert encodings carry, so every expectation below is derived, not hand-transcribed.
private static readonly Guid Sample = new Guid("11223344-5566-7788-99aa-bbccddeeff00");
private static byte[] Wrap(byte[] payload, params byte[] header)
{
var buf = new byte[header.Length + payload.Length];
Buffer.BlockCopy(header, 0, buf, 0, header.Length);
Buffer.BlockCopy(payload, 0, buf, header.Length, payload.Length);
return buf;
}
[Fact]
public void OctetString_ShortForm_Parses()
{
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x10);
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_LongForm_Parses()
{
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x81, 0x10);
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
/// <summary>
/// The regression this class exists for: Intune's <c>1.2.840.113556.5.4</c> extension value is
/// exactly 16 bytes with no <c>04 10</c> prefix, so the strict OCTET-STRING parser bailed on the
/// tag check and the DeviceId was unreadable.
/// </summary>
[Fact]
public void BareSixteenBytes_Parses()
{
var raw = Sample.ToByteArray();
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
/// <summary>
/// A bare GUID may legitimately start with 0x04 (the OCTET STRING tag). That must not be
/// mistaken for a wrapper: a wrapped 16-byte GUID is always >= 18 bytes, so the two forms are
/// disjoint by length and the bare path has to win here.
/// </summary>
[Fact]
public void BareSixteenBytes_StartingWithOctetStringTag_StillParses()
{
var guid = new Guid("aabbcc04-0110-4020-8030-405060708090");
var raw = guid.ToByteArray();
Assert.Equal(0x04, raw[0]); // guard: the byte layout really does start with the ASN.1 tag
Assert.Equal(16, raw.Length);
Assert.Equal(guid, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_IsLittleEndianMsLayout()
{
// 04 10 + the canonical MS binary layout of {11223344-...} => first group byte-reversed.
var raw = new byte[]
{
0x04, 0x10,
0x44, 0x33, 0x22, 0x11,
0x66, 0x55,
0x88, 0x77,
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
};
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void Null_ReturnsNull()
{
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(null));
}
[Fact]
public void Empty_ReturnsNull()
{
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[0]));
}
[Theory]
[InlineData(15)]
[InlineData(17)]
public void BarePayload_OfWrongLength_ReturnsNull(int length)
{
var raw = new byte[length];
for (var i = 0; i < length; i++) raw[i] = (byte)(0x40 + i); // no 0x04 tag, no valid wrapper
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void NonOctetStringTag_ReturnsNull()
{
// e.g. Intune's 1.2.840.113556.5.15 => ASN.1 INTEGER 2.
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[] { 0x02, 0x01, 0x02 }));
}
[Fact]
public void OctetString_WithNonGuidContentLength_ReturnsNull()
{
var raw = Wrap(new byte[8], 0x04, 0x08);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_TruncatedContent_ReturnsNull()
{
// Declares 16 content bytes but only carries 10.
var raw = Wrap(new byte[10], 0x04, 0x10);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
[Fact]
public void OctetString_LongFormWithOversizedLengthHeader_ReturnsNull()
{
// 0x85 => 5 length bytes, above the 4 the parser accepts.
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x85, 0x00, 0x00, 0x00, 0x00, 0x10);
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
}
}
}
@@ -72,6 +72,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.NotEqual(first, second);
}
[Fact]
public void Rotate_writes_a_new_id_and_keeps_the_whiteglove_marker()
{
using var tmp = new TempDirectory();
var sut = new SessionIdPersistence(tmp.Path);
var first = sut.GetOrCreate();
sut.SaveWhiteGloveComplete();
var createdBefore = File.ReadAllText(Path.Combine(tmp.Path, "session.created"));
var rotated = sut.Rotate();
Assert.True(Guid.TryParse(rotated, out _));
Assert.NotEqual(first, rotated);
Assert.Equal(rotated, File.ReadAllText(Path.Combine(tmp.Path, "session.id")));
Assert.Equal(rotated, new SessionIdPersistence(tmp.Path).GetOrCreate());
Assert.True(sut.IsWhiteGloveResume(), "Rotate must not clear whiteglove.complete (unlike Delete)");
Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created")));
Assert.NotEqual(createdBefore, File.ReadAllText(Path.Combine(tmp.Path, "session.created")));
}
[Fact]
public void Ctor_creates_data_directory_when_missing()
{
@@ -96,8 +96,114 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(1, tracker.ConsecutiveFailures);
}
// ── SESSION-OWNER-BINDING ────────────────────────────────────────────
private static BackendAuthException OwnerMismatch()
=> new BackendAuthException("Backend returned 403 Forbidden.", statusCode: 403,
errorCode: AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch);
[Fact]
public async Task Fails_after_five_exceptions_without_auth_error()
public async Task Owner_mismatch_rotates_once_and_re_registers_without_recording_an_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var originalSessionId = config.SessionId;
var rotatedTo = Guid.NewGuid().ToString();
var seenSessionIds = new System.Collections.Generic.List<string>();
var apiClient = new FakeApiClient(
attempt => attempt == 1
? throw OwnerMismatch()
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
onRegister: r => seenSessionIds.Add(r.SessionId));
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var rotations = 0;
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; });
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
}
[Fact]
public async Task Second_owner_mismatch_after_rotation_is_an_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw OwnerMismatch());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; });
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
Assert.Equal(403, result.HttpStatusCode);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, tracker.ConsecutiveFailures);
}
[Fact]
public async Task Owner_mismatch_without_rotation_hook_behaves_like_any_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw OwnerMismatch());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask);
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(1, tracker.ConsecutiveFailures);
Assert.Null(result.RotatedFromSessionId);
}
[Fact]
public async Task Rotation_hook_failure_falls_back_to_auth_failed()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw OwnerMismatch());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => throw new IOException("disk full"));
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(1, tracker.ConsecutiveFailures);
}
[Fact]
public async Task Fails_after_six_exceptions_without_auth_error()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
@@ -111,8 +217,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
backoffDelay: _ => Task.CompletedTask);
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
Assert.Equal(5, apiClient.CallCount);
Assert.Contains("transient attempt 5", result.ErrorMessage);
Assert.Equal(6, apiClient.CallCount);
Assert.Contains("transient attempt 6", result.ErrorMessage);
}
[Fact]
public async Task Waits_for_network_link_before_first_attempt()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var order = new System.Collections.Generic.List<string>();
var apiClient = new FakeApiClient(_ =>
{
order.Add("register");
return new RegisterSessionResponse { Success = true, SessionId = config.SessionId };
});
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask,
networkLinkWait: _ => { order.Add("link-wait"); return Task.CompletedTask; });
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(new[] { "link-wait", "register" }, order);
}
[Fact]
public async Task Network_link_wait_failure_does_not_block_registration()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ =>
new RegisterSessionResponse { Success = true, SessionId = config.SessionId });
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask,
networkLinkWait: _ => throw new InvalidOperationException("probe broken"));
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
}
[Fact]
@@ -163,5 +163,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted));
Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
}
// ==================================================== zero-app evidence label ====
[Fact]
public void Zero_app_user_session_labels_the_evidence_on_signal_and_timeline()
{
// Sessions 81daa77f / 75d6ae8e: with no user-targeted apps IME never writes its
// "Completed user session" line at all, so the verdict rests on a different
// observation. Whoever reads the timeline later must be able to tell which.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.SeedCurrentPhaseForTesting("AccountSetup");
f.Tracker.SeedUserSessionZeroAppsForTesting();
adapter.TriggerUserSessionCompletedFromTest();
Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted));
var evt = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
Assert.Equal(
ImeLogTrackerAdapter.EvidenceZeroUserApps,
evt.Payload!["evidence"]);
}
[Fact]
public void Ordinary_user_session_completion_keeps_the_default_evidence_label()
{
// Mutation proof for the branch above.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.SeedCurrentPhaseForTesting("AccountSetup");
AddApp(f.Tracker, "app-done", AppIntent.Install, AppInstallationState.Installed);
adapter.TriggerUserSessionCompletedFromTest();
var evt = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
Assert.Equal(
ImeLogTrackerAdapter.EvidenceUserSessionCompleted,
evt.Payload!["evidence"]);
}
}
}
@@ -61,6 +61,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Contains("The device is not authorized", ex.Message);
}
[Fact]
public async Task Backend_json_403_carries_the_errorCode_when_present()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
"{\"success\":false,\"message\":\"Session belongs to another device\",\"errorCode\":\"session_owner_mismatch\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(403, ex.StatusCode);
Assert.False(ex.EndpointUnavailable);
Assert.Equal("session_owner_mismatch", ex.ErrorCode);
}
[Fact]
public async Task Backend_json_403_without_errorCode_yields_null()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
"{\"success\":false,\"message\":\"Device validation failed\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Null(ex.ErrorCode);
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("not json", null)]
[InlineData("<html><body>403</body></html>", null)]
[InlineData("{\"errorCode\":42}", null)]
[InlineData("{\"errorCode\":\" \"}", null)]
[InlineData("{\"errorCode\":\"session_owner_mismatch\"}", "session_owner_mismatch")]
[InlineData("{\"ErrorCode\":\"session_owner_mismatch\"}", "session_owner_mismatch")]
[InlineData(" {\"success\":false,\"errorCode\":\" x \"}", "x")]
public void TryExtractErrorCode_is_tolerant(string? body, string? expected)
=> Assert.Equal(expected, BackendApiClient.TryExtractErrorCode(body!));
[Fact]
public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title()
{
@@ -52,6 +52,11 @@
carries the V2 assembly namespace so GatherRuleGuards locates it under
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
<!-- Built-in fallback rule catalogs (BuiltInConfigCatalog): the SAME CI-combined
rules/dist artifacts the backend embeds and serves. Snapshotted per agent build so
the no-config/no-cache fallback still runs full IME tracking instead of nothing. -->
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.ime-log-patterns.json" />
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.gather-rules.json" />
</ItemGroup>
<ItemGroup>
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// deterministic network fingerprint (default gateway IP + MAC, RealmJoin-compatible).
/// Existing DOGroupId/DOGroupIdSource policies are never overwritten.
/// Default: false
/// </summary>
public bool EnableDoGroupIdAutoSet { get; set; } = false;
/// <summary>
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
/// remote config): stamped into the esp_config_detected decision-signal payload so
@@ -254,6 +262,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool UnrestrictedMode { get; set; } = false;
/// <summary>
/// Mirror of <c>AnalyzerConfiguration.EnableRealmJoinWatcher</c> (remote, per tenant).
/// Gates the RealmJoin built-in sections of the diagnostics package — the package
/// service only sees this configuration, not the remote response. The RealmJoin host
/// itself keeps reading the remote Analyzers block. Default: false.
/// </summary>
public bool EnableRealmJoinWatcher { get; set; } = false;
/// <summary>
/// Sanitized command-line arguments for the agent_started event.
/// Secrets (e.g. bootstrap token) are redacted before assignment.
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Compiled-in fallback copies of the global rule catalogs, embedded from the same
/// CI-generated <c>rules/dist</c> artifacts the backend seeds and serves — single
/// source by construction: each agent release snapshots the catalog state of its own
/// build, so the offline fallback can never drift further than the running agent
/// version itself (no manually maintained default to forget).
/// <para>
/// Consumed only by <see cref="RemoteConfigService"/>'s built-in default config,
/// i.e. when a session has NO live config and NO cache. Before this existed, that
/// path shipped an empty <c>ImeLogPatterns</c> list, which left the entire IME
/// tracking pipeline (app installs, platform/remediation scripts, health-script
/// compliance, IME ESP-phase signals) dead for the whole session — the core of
/// enrollment monitoring. A live fetch (startup, post-registration recovery or
/// rotate_config) always wins over this catalog.
/// </para>
/// Fail-soft: any load/parse error returns an empty list (the pre-catalog behaviour).
/// </summary>
internal static class BuiltInConfigCatalog
{
/// <summary>Enabled built-in IME log patterns from the embedded catalog.</summary>
public static List<ImeLogPattern> GetEnabledImeLogPatterns(AgentLogger logger)
=> LoadEnabled<ImeLogPattern>("ime-log-patterns.json", p => p.Enabled, logger);
/// <summary>Enabled built-in gather rules from the embedded catalog.</summary>
public static List<GatherRule> GetEnabledGatherRules(AgentLogger logger)
=> LoadEnabled<GatherRule>("gather-rules.json", r => r.Enabled, logger);
private static List<T> LoadEnabled<T>(string name, Func<T, bool> isEnabled, AgentLogger logger)
{
try
{
var assembly = typeof(BuiltInConfigCatalog).Assembly;
using (var stream = assembly.GetManifestResourceStream(
"AutopilotMonitor.Agent.V2.Core.Resources." + name))
{
if (stream == null)
{
logger?.Warning($"Built-in config catalog resource '{name}' not found — fallback runs without it.");
return new List<T>();
}
using (var reader = new StreamReader(stream))
{
var wrapper = JsonConvert.DeserializeObject<RulesFile<T>>(reader.ReadToEnd());
var all = wrapper?.Rules ?? new List<T>();
return all.Where(isEnabled).ToList();
}
}
}
catch (Exception ex)
{
logger?.Warning($"Built-in config catalog '{name}' failed to load ({ex.Message}) — fallback runs without it.");
return new List<T>();
}
}
/// <summary>Shape of the CI-combined catalog files: <c>{ "rules": [...] }</c>.</summary>
private sealed class RulesFile<T>
{
[JsonProperty("rules")]
public List<T> Rules { get; set; }
}
}
}
@@ -94,12 +94,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// a tenant admin can clear a previously-applied custom NTP server.
agentConfig.NtpServer = remoteConfig.NtpServer;
agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet;
agentConfig.EnableDoGroupIdAutoSet = remoteConfig.EnableDoGroupIdAutoSet;
// ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off"
// which must clear any CLI-initial default).
agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled;
agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List<DiagnosticsLogPath>();
// The diagnostics package gates its RealmJoin sections on the tenant's watcher
// toggle but never sees the remote response — mirror the one Analyzers knob here.
// The remaining Analyzers flags flow untouched to DefaultComponentFactory.
agentConfig.EnableRealmJoinWatcher = remoteConfig.Analyzers?.EnableRealmJoinWatcher ?? false;
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
@@ -42,6 +42,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
TimeSpan.FromSeconds(60),
};
/// <summary>
/// Upper bound for the pre-fetch network-link wait on the initial startup fetch.
/// Deliberately longer than the registration gate's 15s: the fetch runs FIRST after a
/// boot-time relaunch, when Wi-Fi may still be associating (802.1X, OOBE), and a fetch
/// burned into a dead link strands the whole session on built-in defaults — tenant aebdce78
/// audit 2026-08-24: ~50% of sessions lost their tenant config exactly this way.
/// </summary>
internal static readonly TimeSpan InitialFetchNetworkLinkWaitMax = TimeSpan.FromSeconds(60);
/// <summary>
/// Gets the current configuration (thread-safe)
/// </summary>
@@ -76,6 +85,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// <summary>HTTP status for auth failures (401/403), null otherwise.</summary>
public int? LastFetchAuthStatusCode { get; private set; }
/// <summary>
/// True once <see cref="TryRecoverConfigAsync"/> replaced a failed startup fetch with a
/// live config. The <c>LastFetch*</c> properties intentionally keep describing the failed
/// startup fetch so the wire event can report both the original failure and the recovery.
/// </summary>
public bool RecoveredAfterFetchFailure { get; private set; }
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null, AuthFailureTracker authFailureTracker = null)
{
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
@@ -116,6 +132,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
LastFetchFailureMessage = null;
LastFetchAuthStatusCode = null;
// Initial startup fetch only: give a still-associating NIC a chance to come up
// before the first attempt. Without this the retry budget (10/30/60s) is burned
// into a provably dead link — the agent starts at boot, Wi-Fi associates minutes
// later, and the session runs on defaults although the backend was never the
// problem. The rotate_config path (retryOnTransientErrors:false) runs mid-session
// on a proven link and must stay latency-bounded, so it skips the gate.
if (retryOnTransientErrors)
{
try { await WaitForNetworkLinkAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.Debug($"Pre-fetch network wait failed ({ex.Message}) — fetching anyway."); }
}
var maxAttempts = retryOnTransientErrors ? InitialFetchRetryBackoff.Length + 1 : 1;
Exception lastException = null;
@@ -203,14 +231,63 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
return cached;
}
// Fall back to defaults
_logger.Info("No cached config available, using defaults (all optional collectors disabled)");
// Fall back to built-in defaults (collector/analyzer defaults from the shared
// model initializers + the compiled-in rule catalogs).
var defaults = CreateDefaultConfig();
_logger.Info($"No cached config available, using built-in defaults (ConfigVersion=0, " +
$"{defaults.ImeLogPatterns?.Count ?? 0} built-in IME patterns, " +
$"{defaults.GatherRules?.Count ?? 0} built-in gather rules)");
SetConfig(defaults);
LastFetchOutcome = RemoteConfigFetchOutcome.UsedDefaults;
return defaults;
}
/// <summary>
/// Single-attempt recovery fetch for a session whose startup fetch failed. Called right
/// after session registration succeeded — registration proves the backend is reachable,
/// so the config that Phase 4 could not get is one cheap call away instead of the whole
/// session being stranded on cache/defaults (only a manual <c>rotate_config</c> action
/// could rescue it before this existed).
/// <para>
/// Unlike <see cref="FetchConfigAsync"/> this does NOT reset the <c>LastFetch*</c>
/// observability state — those keep describing the failed startup fetch so
/// <c>remote_config_fetch_failed</c> can report the original failure AND the recovery.
/// Returns the recovered config, or null when there is nothing to recover (startup fetch
/// succeeded / never ran) or the recovery attempt itself failed. Never throws.
/// </para>
/// </summary>
public async Task<AgentConfigResponse> TryRecoverConfigAsync()
{
if (LastFetchOutcome != RemoteConfigFetchOutcome.FromCache &&
LastFetchOutcome != RemoteConfigFetchOutcome.UsedDefaults)
return null;
try
{
_logger.Info("Retrying remote config fetch after successful session registration...");
var config = await CallBackendAsync().ConfigureAwait(false);
if (config == null)
{
_logger.Warning("Config recovery fetch returned empty response — keeping fallback config.");
return null;
}
_logger.Info($"Remote config recovered: ConfigVersion={config.ConfigVersion}, " +
$"GatherRules={config.GatherRules?.Count ?? 0}");
LogCollectorSettings(config.Collectors);
SetConfig(config);
CacheConfig(config);
_authFailureTracker?.RecordSuccess();
RecoveredAfterFetchFailure = true;
return config;
}
catch (Exception ex)
{
_logger.Warning($"Config recovery fetch failed ({ex.Message}) — keeping fallback config.");
return null;
}
}
private void SetConfig(AgentConfigResponse config)
{
lock (_configLock)
@@ -319,6 +396,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
private AgentConfigResponse CreateDefaultConfig()
{
// Everything not set here falls to the shared AgentConfigResponse property
// initializers — the same values the backend uses as its `??` fallbacks, so the
// scalar knobs cannot drift. The rule catalogs are the one thing initializers
// cannot supply: without them the whole IME tracking pipeline is dead, so they
// come from the compiled-in snapshot of the served catalogs (BuiltInConfigCatalog).
return new AgentConfigResponse
{
ConfigVersion = 0,
@@ -330,8 +412,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
EnableGatherRuleDebugLog = false,
EnableEspContinueAnywayObservation = false,
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new System.Collections.Generic.List<GatherRule>(),
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
GatherRules = BuiltInConfigCatalog.GetEnabledGatherRules(_logger),
ImeLogPatterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(_logger)
};
}
@@ -358,6 +440,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
protected virtual Task DelayBetweenAttemptsAsync(TimeSpan delay)
=> Task.Delay(delay);
/// <summary>
/// Awaits the pre-fetch network-link gate (initial startup fetch only). Production
/// polls the link-level signal via <see cref="NetworkLinkWait"/> bounded by
/// <see cref="InitialFetchNetworkLinkWaitMax"/>; tests subclass to record the call
/// and return immediately.
/// </summary>
protected virtual Task WaitForNetworkLinkAsync()
=> NetworkLinkWait.WaitAsync(_logger, InitialFetchNetworkLinkWaitMax, "Remote config fetch");
public void Dispose()
{
// Nothing to dispose
@@ -1042,6 +1042,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
internal string LastEspPhaseDetected => _lastEspPhaseDetected;
/// <summary>
/// True while the user-scope ESP phase is the tracker's current phase. Every user-phase
/// probe and the zero-apps evidence gate on this — outside AccountSetup the IME lines
/// belong to the device phase or the pre-sign-in (defaultuser0) frame.
/// </summary>
private bool IsUserEspPhaseActive =>
string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// True once IME reported zero targeted Win32 apps for a user session while the user ESP
/// phase was active. Read by the adapter to label the evidence behind
/// <c>ime_user_session_completed</c>.
/// </summary>
internal bool UserSessionZeroAppsObserved => _userSessionZeroAppsObserved;
/// <summary>
/// IME-USER-SESSION-ZERO-APPS: IME enumerated the user session and found no targeted
/// Win32 apps. Its zero-app branch then <c>continue</c>s past the "Completed user
/// session" log statement, so this line is the only completion evidence such a device
/// ever produces — and IME itself treats the same condition as finished
/// (<c>ProviderResult.ProvisioningComplete</c> in the user check-in path, and an ESP
/// lock-in with an empty application list in the poller path).
/// <para>
/// Deliberately routed through <see cref="OnUserSessionCompleted"/> rather than a
/// separate callback: downstream this IS a completed user session, and the adapter's
/// pending-apps deferral plus its fire-once flag then apply unchanged.
/// </para>
/// <para>
/// Phase guard: firing before AccountSetup would burn the adapter's fire-once flag on a
/// pre-sign-in timestamp that <c>IsImeUserSessionGenuine</c> can never accept. The
/// observation is remembered instead and replayed by
/// <see cref="HandleEspPhaseDetected"/> on the transition into the user phase.
/// </para>
/// </summary>
private void HandleUserSessionZeroApps()
{
_userSessionZeroAppsObserved = true;
if (!IsUserEspPhaseActive)
{
_logger.Debug(
"ImeLogTracker: zero-app user session observed before the user ESP phase " +
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup");
return;
}
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed");
OnUserSessionCompleted?.Invoke();
}
private void HandleEspPhaseDetected(string espPhaseString)
{
// Validate phase and get its ordinal for forward-only enforcement
@@ -1120,6 +1170,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
ActivatePatterns(logPhaseIsCurrentPhase);
OnEspPhaseChanged?.Invoke(espPhaseString);
// Replay a zero-app user session observed before this phase was known. IME logs its
// check-in lines independently of when the agent first parses the ESP phase marker,
// so the evidence can legitimately arrive first; without this the only completion
// evidence a zero-app device produces would be dropped on the floor.
if (isFirstOrChangedPhase && IsUserEspPhaseActive && _userSessionZeroAppsObserved)
{
_logger.Info("ImeLogTracker: replaying zero-app user session evidence observed before the user ESP phase");
OnUserSessionCompleted?.Invoke();
}
}
private void HandleEspTrackStatus(Match match)
@@ -387,6 +387,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
OnUserSessionCompleted?.Invoke();
break;
case "usersessionzeroapps":
// IME found no targeted user apps — its zero-app branch skips the
// "Completed user session" line, so this is the equivalent evidence.
HandleUserSessionZeroApps();
break;
case "updatestateinstalled":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installed);
@@ -129,6 +129,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private CancellationTokenSource _cts;
private bool _allAppsCompletedFired;
// Sticky: IME enumerated a user session and reported ZERO targeted Win32 apps
// (IME-USER-SESSION-ZERO-APPS). IME's zero-app branch returns before it writes the
// "Completed user session" line, so on a device with no user-targeted apps that line
// never appears and every user-phase completion probe would starve forever. This flag
// IS the user-phase completion evidence for that shape. Set only while the user ESP
// phase is active, or replayed on the transition into it.
private bool _userSessionZeroAppsObserved;
// State persistence: saves tracker state to disk so agent restart continues
// from the exact log position without re-parsing or re-building ignore lists.
private readonly ImeTrackerStatePersistence _statePersistence;
@@ -341,6 +349,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_lastEspPhaseDetected = phaseName;
}
/// <summary>
/// Test-only seam: marks the zero-targeted-apps user session as observed without driving
/// the IME-USER-SESSION-ZERO-APPS pattern through a log line. The pattern-to-flag path
/// itself is covered end-to-end by <c>ImeLogTrackerZeroUserAppsTests</c>.
/// </summary>
internal void SeedUserSessionZeroAppsForTesting()
{
_userSessionZeroAppsObserved = true;
}
/// <summary>
/// Returns a deduped union of phase-snapshotted apps plus the live
/// <see cref="PackageStates"/> (current phase). Live entries win on Id collision.
@@ -395,11 +413,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
try
{
if (!string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase))
if (!IsUserEspPhaseActive)
return false;
var apps = new List<AppPackageState>(_packageStates);
if (apps.Count == 0) return false;
// Zero user-targeted apps is the MOST settled state there is, but the
// conservative "at least one required app" rule below reads it as "not
// settled" and starves the AccountSetup synthesis. Only an explicit IME
// observation lifts that — never a merely empty list, which also means
// "phase just cleared" or "apps not surfaced yet".
if (apps.Count == 0)
return _userSessionZeroAppsObserved;
var sawRequired = false;
foreach (var pkg in apps)
@@ -746,6 +771,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_currentPhaseOrder = state.CurrentPhaseOrder;
_lastEspPhaseDetected = state.LastEspPhaseDetected;
_allAppsCompletedFired = state.AllAppsCompletedFired;
_userSessionZeroAppsObserved = state.UserSessionZeroAppsObserved;
_logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
// Restore seen app IDs
@@ -861,6 +887,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
CurrentPhaseOrder = _currentPhaseOrder,
LastEspPhaseDetected = _lastEspPhaseDetected,
AllAppsCompletedFired = _allAppsCompletedFired,
UserSessionZeroAppsObserved = _userSessionZeroAppsObserved,
LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
SeenAppIds = _seenAppIds.ToList(),
IgnoreList = _packageStates.IgnoreList.ToList(),
@@ -109,6 +109,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public int CurrentPhaseOrder { get; set; }
public string LastEspPhaseDetected { get; set; }
public bool AllAppsCompletedFired { get; set; }
/// <summary>
/// IME reported zero targeted Win32 apps for a user session. Absent in state files
/// written before this field existed — deserializing to false there is correct: the
/// tracker simply re-observes the line on the next IME check-in.
/// </summary>
public bool UserSessionZeroAppsObserved { get; set; }
public bool LogPhaseIsCurrentPhase { get; set; }
public List<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
@@ -0,0 +1,681 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Watches the <c>System</c> event log for two enrollment blind spots and forwards them as
/// timeline ground truth:
/// <list type="bullet">
/// <item><b>System clock steps</b> (Kernel-General EventID 1) → <c>system_clock_changed</c>.
/// The payload carries <c>oldTime</c>/<c>newTime</c>/<c>timeDeltaMs</c> plus the
/// setting process — the "clock set from X to Y" fact that today has to be inferred
/// from timestamp discontinuities. w32time also logs frequent 1 ms micro-slews on the
/// same EventID, so emissions are gated on <see cref="MinClockDeltaMs"/>.</item>
/// <item><b>Completed sleep episodes</b> → <c>system_sleep_episode</c>. Classic S3/S4 comes
/// from Power-Troubleshooter EventID 1 (one event per episode at wake, carrying
/// SleepTime + WakeTime). Modern Standby comes from Kernel-Power EventID 507 (exit),
/// whose payload carries the scenario duration and the actual sleep residency; the
/// enter time is derived as exit − duration. Kernel-Power 506 (enter) is deliberately
/// NOT consumed: 507 already carries everything, and 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.</item>
/// </list>
/// <para>
/// <b>Payload times are authoritative.</b> The decisive clock correction (w32time fixing a
/// wrong BIOS clock) happens exactly when the device clock is untrustworthy, and the backend
/// clamps implausible event timestamps to server time. All semantic instants therefore travel
/// as explicit ISO-8601 payload fields, never only as the event's own <c>Timestamp</c>.
/// </para>
/// <para>
/// <b>Backfill + cross-restart dedup</b> mirror <see cref="WindowsUpdateTracker"/>: the System
/// .evtx persists across the OOBE reboots, so a startup scan recovers pre-agent clock steps
/// and standby episodes; a persisted RecordId watermark plus an intra-run seen-set (a HashSet,
/// NOT a high-water mark — see the field comment) keep restarts from re-emitting.
/// </para>
/// </summary>
internal sealed class SystemTimelineTracker : IDisposable
{
internal const string Channel = "System";
internal const string ProviderKernelGeneral = "Microsoft-Windows-Kernel-General"; // EventID 1: system time changed
internal const string ProviderPowerTroubleshooter = "Microsoft-Windows-Power-Troubleshooter"; // EventID 1: resumed from classic sleep/hibernate
internal const string ProviderKernelPower = "Microsoft-Windows-Kernel-Power"; // EventID 507: Modern Standby exit
internal const int EventId_ClockChange = 1;
internal const int EventId_ClassicResume = 1;
internal const int EventId_ModernStandbyExit = 507;
internal const string WatermarkStateFileName = "system-timeline-watermark.json";
// w32time logs Kernel-General 1 for routine 1 ms slew corrections (verified live: several
// per hour). Only genuine steps are timeline-relevant; anything below this is claimed in
// the dedup set but never emitted.
internal const long MinClockDeltaMs = 2000;
// A step of at least 5 minutes can invalidate token/cert validation windows and visibly
// reorders the timeline (matches the web's TimeJumpBadge threshold) — surface it fast.
internal const long ClockDeltaWarningMs = 5 * 60 * 1000;
// Modern Standby logs a 506/507 pair for every screen-off scenario, including seconds-long
// ones where the device never actually slept (SleepDurationInUs 0). Only episodes with
// real sleep residency are emitted. Classic Power-Troubleshooter episodes have no floor:
// they only exist for genuinely completed S3/S4 transitions.
internal const long MinSleepDurationSeconds = 60;
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly bool _backfillEnabled;
private readonly int _backfillLookbackMinutes;
private readonly string _stateDirectory;
private EventLogWatcher _watcher;
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
//
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
// Immutable during this run — records at/below it were already emitted before this process
// started and must not be re-emitted by the backfill scan.
private long _restartWatermark = -1;
// RecordIds claimed in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
// high-water mark: the live watcher is armed before the backfill runs, so a live record with
// a HIGHER RecordId can be processed before/during backfill. A high-water mark would then
// make the dedup skip every older, never-emitted backfill record — silently dropping exactly
// the pre-agent clock corrections and standby episodes this tracker exists for. The targeted
// records are low-volume, so the set stays tiny.
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
// Highest RecordId claimed so far (max of restart watermark + this run). Persisted so the
// next run's restart watermark is correct.
private long _maxEmittedRecordId = -1;
private readonly object _watermarkLock = new object();
public SystemTimelineTracker(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
bool backfillEnabled = true,
int backfillLookbackMinutes = 1440,
string stateDirectory = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_backfillEnabled = backfillEnabled;
_backfillLookbackMinutes = backfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
}
public void Start()
{
LoadWatermark();
StartWatcher();
if (_backfillEnabled && _backfillLookbackMinutes > 0)
BackfillRecentEvents();
else
_logger.Info("SystemTimeline backfill disabled by config");
}
public void Stop()
{
if (_watcher == null) return;
try
{
_watcher.Enabled = false;
_watcher.Dispose();
_logger.Info("SystemTimeline watcher stopped");
}
catch (Exception ex)
{
_logger.Error("Error stopping SystemTimeline watcher", ex);
}
finally
{
_watcher = null;
}
}
public void Dispose() => Stop();
// -----------------------------------------------------------------------
// Watcher lifecycle
// -----------------------------------------------------------------------
private void StartWatcher()
{
try
{
var query = new EventLogQuery(Channel, PathType.LogName, BuildXPath());
_watcher = new EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"SystemTimeline watcher started: {Channel} (clock changes + sleep episodes)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"SystemTimeline event log not found: {Channel} (normal on non-Windows test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"SystemTimeline watcher access denied for {Channel}: {ex.Message}");
}
catch (Exception ex)
{
_logger.Error($"Failed to start SystemTimeline watcher for {Channel}", ex);
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
collectorName: "SystemTimelineTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
}
}
/// <summary>
/// Provider-qualified XPath for the three targeted signals. Provider filtering is mandatory
/// on the System channel — EventID 1 alone matches many unrelated providers.
/// Exposed as internal for tests.
/// </summary>
internal static string BuildXPath() =>
"*[System[" + ProviderIdClauses() + "]]";
/// <summary>Backfill variant of <see cref="BuildXPath"/> with the lookback window applied.</summary>
internal static string BuildBackfillXPath(long lookbackMs) =>
"*[System[(" + ProviderIdClauses() + $") and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
private static string ProviderIdClauses() =>
$"(Provider[@Name='{ProviderKernelGeneral}'] and EventID={EventId_ClockChange})" +
$" or (Provider[@Name='{ProviderPowerTroubleshooter}'] and EventID={EventId_ClassicResume})" +
$" or (Provider[@Name='{ProviderKernelPower}'] and EventID={EventId_ModernStandbyExit})";
// -----------------------------------------------------------------------
// Event processing
// -----------------------------------------------------------------------
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
ProcessRecord(e.EventRecord, isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing SystemTimeline event", ex);
}
}
private void ProcessRecord(EventRecord record, bool isBackfill)
{
var recordId = record.RecordId ?? -1;
// Cheap dedup gate BEFORE the XML rendering work.
if (IsAlreadyProcessed(recordId))
return;
string xml = null;
try { xml = record.ToXml(); }
catch { /* fall through with empty EventData */ }
ProcessEvent(
providerName: record.ProviderName,
eventId: record.Id,
recordId: recordId,
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
eventData: ParseEventData(xml),
isBackfill: isBackfill);
}
/// <summary>
/// Core processing extracted to primitive inputs so tests can drive it without synthesizing
/// an abstract, Windows-only <see cref="EventRecord"/> (WindowsUpdateTracker test-seam
/// pattern; a plain dictionary instead of named args because three providers with ~10
/// payload fields each make a fixed signature impractical).
/// </summary>
internal void ProcessEvent(
string providerName,
int eventId,
long recordId,
DateTime? timeCreatedUtc,
Dictionary<string, string> eventData,
bool isBackfill)
{
if (!MarkProcessed(recordId))
return; // already emitted (cross-restart or duplicate delivery)
eventData = eventData ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.Equals(providerName, ProviderKernelGeneral, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClockChange)
HandleClockChange(recordId, timeCreatedUtc, eventData, isBackfill);
else if (string.Equals(providerName, ProviderPowerTroubleshooter, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClassicResume)
HandleClassicResume(recordId, timeCreatedUtc, eventData, isBackfill);
else if (string.Equals(providerName, ProviderKernelPower, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ModernStandbyExit)
HandleModernStandbyExit(recordId, timeCreatedUtc, eventData, isBackfill);
// Anything else slipped past the XPath filter — ignore.
}
// -----------------------------------------------------------------------
// system_clock_changed (Kernel-General EventID 1)
// -----------------------------------------------------------------------
private void HandleClockChange(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("NewTime", out var newTimeRaw);
eventData.TryGetValue("OldTime", out var oldTimeRaw);
var newTime = TryParseUtc(newTimeRaw);
var oldTime = TryParseUtc(oldTimeRaw);
// Signed delta preferably from the authoritative instants (NewTime − OldTime carries
// the direction unambiguously); TimeDeltaInMs is the fallback when either fails to
// parse. Records with neither are unusable and skipped (already claimed by dedup).
long deltaMs;
if (newTime.HasValue && oldTime.HasValue)
{
deltaMs = (long)Math.Round((newTime.Value - oldTime.Value).TotalMilliseconds);
}
else if (eventData.TryGetValue("TimeDeltaInMs", out var deltaRaw) &&
long.TryParse(deltaRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDelta))
{
deltaMs = parsedDelta;
}
else
{
_logger.Debug($"SystemTimeline: clock-change record {recordId} has no usable times/delta — skipped");
return;
}
if (Math.Abs(deltaMs) < MinClockDeltaMs)
return; // w32time micro-slew — claimed (MarkProcessed) but never emitted
eventData.TryGetValue("Reason", out var reasonRaw);
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason);
var reasonText = reason == 1 ? "application_set"
: reason == 2 ? "hardware_clock_sync"
: "unknown";
eventData.TryGetValue("ProcessName", out var processName);
var data = new Dictionary<string, object>
{
{ "timeDeltaMs", deltaMs },
{ "reason", reason },
{ "reasonText", reasonText },
{ "backfilled", isBackfill },
};
// Authoritative instants: re-normalized ISO-8601 when parseable, raw pass-through
// otherwise — the payload must survive even a malformed provider rendering.
data["newTime"] = newTime.HasValue ? newTime.Value.ToString("o") : (object)(newTimeRaw ?? string.Empty);
data["oldTime"] = oldTime.HasValue ? oldTime.Value.ToString("o") : (object)(oldTimeRaw ?? string.Empty);
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (!string.IsNullOrEmpty(processName)) data["processName"] = processName;
if (eventData.TryGetValue("ProcessID", out var pidRaw) &&
int.TryParse(pidRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid))
{
data["processId"] = pid;
}
var direction = deltaMs >= 0 ? "forward" : "backward";
var processLeaf = ProcessLeafName(processName);
var message = $"System clock stepped {direction} {FormatDuration(TimeSpan.FromMilliseconds(Math.Abs(deltaMs)))}" +
$" (reason: {reasonText}" + (processLeaf != null ? $", process: {processLeaf})" : ")");
var isLargeStep = Math.Abs(deltaMs) >= ClockDeltaWarningMs;
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
// The record's own creation time, not UtcNow: backfilled pre-agent clock steps must
// land on the timeline where they happened (WindowsUpdateTracker convention).
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemClockChanged,
Severity = isLargeStep ? EventSeverity.Warning : EventSeverity.Info,
Source = "SystemTimelineWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = isLargeStep,
});
}
// -----------------------------------------------------------------------
// system_sleep_episode — classic sleep/hibernate (Power-Troubleshooter EventID 1)
// -----------------------------------------------------------------------
private void HandleClassicResume(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("SleepTime", out var sleepRaw);
eventData.TryGetValue("WakeTime", out var wakeRaw);
var enteredAt = TryParseUtc(sleepRaw);
var exitedAt = TryParseUtc(wakeRaw);
// EffectiveState 5 = hibernate (S4); TargetState is the fallback when the effective
// state is missing (older event versions).
eventData.TryGetValue("EffectiveState", out var effectiveState);
eventData.TryGetValue("TargetState", out var targetState);
var stateValue = !string.IsNullOrEmpty(effectiveState) ? effectiveState : targetState;
var kind = stateValue == "5" ? "hibernate" : "sleep";
long? durationSeconds = null;
if (enteredAt.HasValue && exitedAt.HasValue && exitedAt.Value > enteredAt.Value)
durationSeconds = (long)Math.Round((exitedAt.Value - enteredAt.Value).TotalSeconds);
var data = new Dictionary<string, object>
{
{ "kind", kind },
{ "backfilled", isBackfill },
};
data["enteredAt"] = enteredAt.HasValue ? enteredAt.Value.ToString("o") : (object)(sleepRaw ?? string.Empty);
data["exitedAt"] = exitedAt.HasValue ? exitedAt.Value.ToString("o") : (object)(wakeRaw ?? string.Empty);
if (durationSeconds.HasValue) data["durationSeconds"] = durationSeconds.Value;
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (eventData.TryGetValue("WakeSourceType", out var wakeTypeRaw) &&
int.TryParse(wakeTypeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var wakeType))
{
data["wakeSourceType"] = wakeType;
}
eventData.TryGetValue("WakeSourceText", out var wakeText);
if (!string.IsNullOrEmpty(wakeText)) data["wakeSourceText"] = wakeText;
var durationLabel = durationSeconds.HasValue
? " after " + FormatDuration(TimeSpan.FromSeconds(durationSeconds.Value))
: string.Empty;
var wakeLabel = !string.IsNullOrEmpty(wakeText) ? $" (wake source: {wakeText})" : string.Empty;
EmitSleepEpisode(timeCreatedUtc, data,
$"Device resumed from {kind}{durationLabel}{wakeLabel}");
}
// -----------------------------------------------------------------------
// system_sleep_episode — Modern Standby (Kernel-Power EventID 507)
// -----------------------------------------------------------------------
private void HandleModernStandbyExit(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("SleepDurationInUs", out var sleepUsRaw);
long.TryParse(sleepUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sleepUs);
var sleepDurationSeconds = sleepUs / 1_000_000;
// Every screen-off scenario logs a 507, including seconds-long ones where the device
// never slept (SleepDurationInUs 0, incl. SleepEntered=false) — one gate covers both.
if (sleepDurationSeconds < MinSleepDurationSeconds)
return; // claimed but not an episode worth the timeline
eventData.TryGetValue("DurationInUs", out var durationUsRaw);
long.TryParse(durationUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var durationUs);
if (durationUs < sleepUs) durationUs = sleepUs; // scenario duration can never undercut its sleep residency
var durationSeconds = durationUs / 1_000_000;
var exitedAt = timeCreatedUtc; // 507 is written at scenario exit
DateTime? enteredAt = exitedAt.HasValue
? exitedAt.Value.AddMilliseconds(-(durationUs / 1000.0))
: (DateTime?)null;
var data = new Dictionary<string, object>
{
{ "kind", "modern_standby" },
{ "durationSeconds", durationSeconds },
{ "sleepDurationSeconds", sleepDurationSeconds },
{ "backfilled", isBackfill },
};
if (enteredAt.HasValue) data["enteredAt"] = enteredAt.Value.ToString("o");
if (exitedAt.HasValue) data["exitedAt"] = exitedAt.Value.ToString("o");
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (eventData.TryGetValue("Reason", out var reasonRaw) &&
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason))
{
data["reason"] = reason;
}
bool? onAcPower = null;
if (eventData.TryGetValue("PowerStateAc", out var acRaw) && bool.TryParse(acRaw, out var ac))
{
onAcPower = ac;
data["onAcPower"] = ac;
}
if (eventData.TryGetValue("BatteryRemainingCapacityOnExit", out var battRaw) &&
long.TryParse(battRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var batt))
{
data["batteryRemainingCapacityOnExit"] = batt;
}
var powerLabel = onAcPower.HasValue ? (onAcPower.Value ? ", on AC" : ", on battery") : string.Empty;
EmitSleepEpisode(timeCreatedUtc, data,
$"Device exited Modern Standby after {FormatDuration(TimeSpan.FromSeconds(durationSeconds))}" +
$" (slept {FormatDuration(TimeSpan.FromSeconds(sleepDurationSeconds))}{powerLabel})");
}
private void EmitSleepEpisode(DateTime? timeCreatedUtc, Dictionary<string, object> data, string message)
{
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
// Wake/exit moment (the record's creation time) — backfilled pre-agent episodes
// must land on the timeline where the device actually woke.
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemSleepEpisode,
Severity = EventSeverity.Info,
Source = "SystemTimelineWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = false,
});
}
// -----------------------------------------------------------------------
// Parsing helpers
// -----------------------------------------------------------------------
/// <summary>
/// Extracts <c>&lt;Data Name="..."&gt;value&lt;/Data&gt;</c> pairs from an event's rendered
/// XML, keyed case-insensitively. Namespace-agnostic (matches on local name). Returns an
/// empty map on null/malformed XML — never throws.
/// </summary>
internal static Dictionary<string, string> ParseEventData(string xml)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(xml)) return result;
try
{
var doc = XDocument.Parse(xml);
foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Data"))
{
var nameAttr = el.Attribute("Name");
if (nameAttr == null || string.IsNullOrEmpty(nameAttr.Value)) continue;
if (!result.ContainsKey(nameAttr.Value))
result[nameAttr.Value] = el.Value;
}
}
catch
{
// Malformed / unexpected XML — best effort, return what we have.
}
return result;
}
internal static DateTime? TryParseUtc(string value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: (DateTime?)null;
}
/// <summary>Compact human duration for messages: "2h 05m", "56m 12s", "45s".</summary>
internal static string FormatDuration(TimeSpan span)
{
if (span.TotalHours >= 1)
return $"{(long)span.TotalHours}h {span.Minutes:D2}m";
if (span.TotalMinutes >= 1)
return $"{span.Minutes}m {span.Seconds:D2}s";
return $"{(long)Math.Ceiling(span.TotalSeconds)}s";
}
/// <summary>Leaf file name of a kernel device path like <c>\Device\HarddiskVolume3\Windows\System32\svchost.exe</c>.</summary>
internal static string ProcessLeafName(string devicePath)
{
if (string.IsNullOrEmpty(devicePath)) return null;
var idx = devicePath.LastIndexOf('\\');
var leaf = idx >= 0 ? devicePath.Substring(idx + 1) : devicePath;
return string.IsNullOrEmpty(leaf) ? null : leaf;
}
// -----------------------------------------------------------------------
// Backfill
// -----------------------------------------------------------------------
private void BackfillRecentEvents()
{
try
{
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
_logger.Info($"SystemTimeline backfill: scanning {Channel} " +
$"(lookback={_backfillLookbackMinutes}min, restartWatermark={_restartWatermark})");
var query = new EventLogQuery(Channel, PathType.LogName, BuildBackfillXPath(lookbackMs));
int processed = 0;
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null)
{
using (record)
{
ProcessRecord(record, isBackfill: true);
processed++;
}
}
}
if (processed == 0)
_logger.Debug($"SystemTimeline backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
else
_logger.Info($"SystemTimeline backfill: scanned {processed} record(s)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"SystemTimeline event log not found during backfill: {Channel} (normal on non-Windows test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"SystemTimeline backfill access denied: {ex.Message}");
}
catch (Exception ex)
{
_logger.Warning($"SystemTimeline backfill failed: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Watermark dedup (cross-restart)
// -----------------------------------------------------------------------
private bool IsAlreadyProcessed(long recordId)
{
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
lock (_watermarkLock)
{
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
}
}
/// <summary>
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
/// see it (caller should evaluate/emit), false if it was already processed — either in a
/// PRIOR run (at/below the restart watermark) or already this run (in the seen-set). Only
/// advances + persists the max on a genuinely-new, higher RecordId. Records without a
/// RecordId (-1) are always processed, never tracked or persisted. Note: threshold-suppressed
/// records are claimed too, so a restart's backfill does not re-evaluate them.
/// </summary>
private bool MarkProcessed(long recordId)
{
if (recordId < 0) return true;
long toPersist = -1;
lock (_watermarkLock)
{
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
return false;
if (recordId > _maxEmittedRecordId)
{
_maxEmittedRecordId = recordId;
toPersist = _maxEmittedRecordId;
}
}
if (toPersist >= 0) PersistWatermark(toPersist);
return true;
}
internal void LoadWatermark()
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
if (!File.Exists(filePath)) return;
try
{
var json = File.ReadAllText(filePath);
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
if (state != null)
{
lock (_watermarkLock)
{
_restartWatermark = state.LastRecordId;
_maxEmittedRecordId = state.LastRecordId;
}
_logger.Info($"SystemTimeline watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to load SystemTimeline watermark: {ex.Message}");
}
}
private void PersistWatermark(long recordId)
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
try
{
Directory.CreateDirectory(_stateDirectory);
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
var state = new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow };
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
var tempPath = filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Copy(tempPath, filePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
_logger.Warning($"Failed to persist SystemTimeline watermark: {ex.Message}");
}
}
internal sealed class WatermarkState
{
public long LastRecordId { get; set; }
public DateTime PersistedUtc { get; set; }
}
}
}
@@ -0,0 +1,64 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// ARP resolution via <c>iphlpapi!SendARP</c> — resolves the MAC address of an on-link IPv4
/// host (in practice: the default gateway). IPv4 only by API design; the OS answers from its
/// ARP cache or sends a real ARP request, so the target must be on the local subnet.
/// </summary>
internal static class ArpNativeMethods
{
[DllImport("iphlpapi.dll", ExactSpelling = true)]
private static extern int SendARP(uint destIP, uint srcIP, byte[] macAddress, ref uint macAddressLength);
/// <summary>
/// Fail-soft MAC resolution. Returns false (with a diagnostic in <paramref name="error"/>)
/// for non-IPv4 addresses, SendARP failures, or unexpected reply lengths — never throws.
/// </summary>
internal static bool TryResolveMac(IPAddress? ipv4Address, out byte[] mac, out string? error)
{
mac = new byte[6];
error = null;
try
{
if (ipv4Address == null || ipv4Address.AddressFamily != AddressFamily.InterNetwork)
{
error = "not an IPv4 address";
return false;
}
// SendARP takes the IPv4 address as a uint in network byte order (first octet in
// the lowest byte).
var addressBytes = ipv4Address.GetAddressBytes();
uint dest = ((uint)addressBytes[3] << 24)
| ((uint)addressBytes[2] << 16)
| ((uint)addressBytes[1] << 8)
| addressBytes[0];
uint len = (uint)mac.Length;
var err = SendARP(dest, 0, mac, ref len);
if (err != 0)
{
error = $"SendARP returned {err}";
return false;
}
if (len != 6)
{
error = $"SendARP returned unexpected MAC length {len}";
return false;
}
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}
}
@@ -0,0 +1,106 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// The single entry point every collector uses to read the current WiFi connection. Owns the
/// tier order so the two callers (<c>DeviceInfoCollector</c>, <c>NetworkChangeDetector</c>)
/// cannot drift apart:
/// <list type="number">
/// <item><see cref="WifiInfoReader"/> — native WLAN API. Full payload (SSID, signal, PHY,
/// channel), but Windows 11 24H2+ requires precise-location consent for it.</item>
/// <item><see cref="WinRtWifiSsidReader"/> — WinRT <c>GetConnectedSsid</c>. SSID only, but
/// NOT location-gated; this is the tier that carries 24H2+ devices whose image has location
/// services off (the common OOBE case — see that class for the mechanism).</item>
/// <item><see cref="NetshWifiFallback"/> — process spawn, localized output parsing. Last
/// resort: it hangs off the same location gate as tier 1, so it only helps when the native
/// API failed for an unrelated reason.</item>
/// </list>
/// Never throws. Returns null when the device has no readable WiFi connection.
/// </summary>
internal static class WifiInfoProvider
{
/// <summary>
/// <see cref="WifiConnectionInfo.DataLimitedReason"/> value for a payload cut down to the
/// SSID by the Windows 11 24H2 location gate. Travels verbatim into the
/// <c>wifi_signal_info</c> payload as <c>wifiDataLimitedReason</c> and is matched by the
/// portal's WiFi card — a published contract, so do not rename it.
/// </summary>
internal const string LocationServicesOff = "location_services_off";
/// <summary>
/// True when the native tier reported <c>ERROR_ACCESS_DENIED</c> — "rc=5" but not
/// "rc=50"/"rc=5023"; the diagnostics string ends the code with ';' or end-of-line.
/// That is the 24H2 location gate, not a driver or hardware fault.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static bool IsLocationGateDenied(string nativeDiag)
=> !string.IsNullOrEmpty(nativeDiag) && Regex.IsMatch(nativeDiag, @"rc=5(?!\d)");
/// <summary>
/// Reads the current WiFi connection, walking the tiers until one yields something.
/// <paramref name="preferredInterfaceId"/> is the adapter GUID from
/// <c>NetworkInterface.Id</c> (tier 1 only). <paramref name="diagnostics"/> is null on
/// success and otherwise explains what every tier reported — log it at Warning: this is
/// the only field evidence for a missing <c>wifi_signal_info</c> event.
/// </summary>
internal static WifiConnectionInfo TryRead(Guid? preferredInterfaceId, out string diagnostics)
{
var native = WifiInfoReader.TryGetCurrentConnection(preferredInterfaceId, out var nativeDiag);
if (native != null)
{
diagnostics = null;
return native;
}
var ssid = WinRtWifiSsidReader.TryGetConnectedSsid(out var winRtDiag);
if (ssid != null)
{
diagnostics = null;
return new WifiConnectionInfo
{
Ssid = ssid,
// Only claim the location gate when the native tier actually said so. Any
// other native failure (no WLAN service, no connected interface) leaves this
// null rather than blaming a setting we did not observe.
DataLimitedReason = IsLocationGateDenied(nativeDiag) ? LocationServicesOff : null,
};
}
var netsh = NetshWifiFallback.TryRead();
if (netsh != null)
{
diagnostics = null;
return netsh;
}
diagnostics = BuildDiagnostics(nativeDiag, winRtDiag);
return null;
}
/// <summary>
/// Composes the all-tiers-failed message. A native <c>rc=5</c> (ERROR_ACCESS_DENIED) is
/// spelled out: that is the 24H2 location gate, not a driver or hardware fault, and it
/// takes the netsh tier down with it — without this hint the log reads like a mystery.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string BuildDiagnostics(string nativeDiag, string winRtDiag)
{
var text = new StringBuilder("no WiFi info from any reader");
text.Append(" — native: ").Append(string.IsNullOrEmpty(nativeDiag) ? "no connected WLAN interface" : nativeDiag);
text.Append("; WinRT: ").Append(string.IsNullOrEmpty(winRtDiag) ? "no SSID" : winRtDiag);
text.Append("; netsh: no parsable output");
if (IsLocationGateDenied(nativeDiag))
{
text.Append(". 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.");
}
return text.ToString();
}
}
}
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// <summary>
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
/// on the native WLAN API path; null when the netsh fallback could not parse it, and
/// always null on the WinRT tier (<see cref="WinRtWifiSsidReader"/> yields SSID only).
/// </summary>
public int? SignalPercent { get; internal set; }
@@ -24,6 +25,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
public int? Channel { get; internal set; }
/// <summary>
/// Why the payload is incomplete, when that is known — currently only
/// <c>location_services_off</c>: the SSID came from the ungated WinRT tier because the
/// native API was denied by the Windows 11 24H2 location gate, so signal, PHY and
/// channel have no source. Null whenever the reason is unknown; never a guess.
/// </summary>
public string DataLimitedReason { get; internal set; }
}
/// <summary>
@@ -0,0 +1,146 @@
using System;
using System.Collections;
using System.Reflection;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Reads the SSID of the current WLAN connection via WinRT
/// <c>Windows.Networking.Connectivity.WlanConnectionProfileDetails.GetConnectedSsid</c>.
/// <para>
/// <b>Why this exists.</b> Windows 11 24H2 put the native WLAN API behind precise-location
/// consent: <c>WlanQueryInterface</c> with <c>wlan_intf_opcode_current_connection</c> — the
/// exact opcode <see cref="WifiInfoReader"/> needs — returns <c>ERROR_ACCESS_DENIED</c>
/// without it, and <c>netsh wlan</c> is gated by the same rule, so
/// <see cref="NetshWifiFallback"/> fails in lockstep. The consent prompt only appears for
/// processes in the user's context outside <c>C:\Windows\System32</c>, so the agent — SYSTEM,
/// during OOBE, before the user ever sees the privacy page — is denied silently. Microsoft
/// names <c>GetConnectedSsid</c> as the replacement for exactly this case; it is not
/// location-gated.
/// </para>
/// <para>
/// Resolved through the CLR's built-in WinRT projection — no NuGet package, no winmd
/// reference — the same mechanism <see cref="OobeStateReader"/> uses (validated from the
/// SYSTEM service context). Available since Windows 10 1507. SSID only: signal quality,
/// PHY type and channel have no ungated source, so they stay null on this path.
/// </para>
/// </summary>
internal static class WinRtWifiSsidReader
{
// Resolved once per process. Benign race: concurrent first calls resolve the same
// members and the assignments are idempotent — no lock needed.
private static MethodInfo _getInternetConnectionProfile;
private static MethodInfo _getConnectionProfiles;
private static bool _initTried;
/// <summary>
/// Returns the SSID of the connected WLAN, or null when the device is not on WiFi, the
/// WinRT contract is unavailable, or any part of the read fails. Never throws.
/// <paramref name="diagnostics"/> carries the reason when the result is null.
/// </summary>
internal static string TryGetConnectedSsid(out string diagnostics)
{
try
{
EnsureInitialized();
if (_getInternetConnectionProfile == null)
{
diagnostics = "WinRT NetworkInformation unavailable";
return null;
}
// The internet-bearing profile is the one the device is actually enrolling over.
var ssid = ReadSsid(_getInternetConnectionProfile.Invoke(null, null));
if (ssid != null)
{
diagnostics = null;
return ssid;
}
// No internet profile (captive portal, limited connectivity) or it is not WLAN —
// fall back to the first connected profile that carries WLAN details.
ssid = ScanConnectionProfiles();
diagnostics = ssid == null ? "no connected WLAN profile" : null;
return ssid;
}
catch (Exception ex)
{
diagnostics = $"WinRT exception {ex.GetType().Name}: {ex.Message}";
return null;
}
}
private static void EnsureInitialized()
{
if (_initTried) return;
var type = Type.GetType(
"Windows.Networking.Connectivity.NetworkInformation, Windows.Networking.Connectivity, ContentType=WindowsRuntime",
throwOnError: false);
_getInternetConnectionProfile = type?.GetMethod(
"GetInternetConnectionProfile", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null);
_getConnectionProfiles = type?.GetMethod(
"GetConnectionProfiles", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null);
_initTried = true;
}
/// <summary>
/// Walks every known connection profile and returns the SSID of the first one that is
/// both connected and WLAN. Returns null when none qualifies.
/// </summary>
private static string ScanConnectionProfiles()
{
if (_getConnectionProfiles == null)
return null;
if (!(_getConnectionProfiles.Invoke(null, null) is IEnumerable profiles))
return null;
foreach (var profile in profiles)
{
if (!IsConnected(profile))
continue;
var ssid = ReadSsid(profile);
if (ssid != null)
return ssid;
}
return null;
}
/// <summary>
/// <c>ConnectionProfile.GetNetworkConnectivityLevel() != NetworkConnectivityLevel.None</c>.
/// A profile the device is not currently on would otherwise leak a stale SSID.
/// Unreadable level counts as not connected.
/// </summary>
private static bool IsConnected(object profile)
{
var level = profile?.GetType()
.GetMethod("GetNetworkConnectivityLevel", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null)
?.Invoke(profile, null);
return level != null && Convert.ToInt32(level) != 0; // NetworkConnectivityLevel.None
}
/// <summary>
/// <c>ConnectionProfile.WlanConnectionProfileDetails.GetConnectedSsid()</c>, or null when
/// the profile is not a WLAN profile or the SSID is empty (hidden network).
/// </summary>
private static string ReadSsid(object profile)
{
var details = profile?.GetType()
.GetProperty("WlanConnectionProfileDetails", BindingFlags.Public | BindingFlags.Instance)
?.GetValue(profile);
var ssid = details?.GetType()
.GetMethod("GetConnectedSsid", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null)
?.Invoke(details, null) as string;
return string.IsNullOrEmpty(ssid) ? null : ssid;
}
}
}
@@ -0,0 +1,68 @@
#nullable enable
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring
{
/// <summary>
/// Shared "which NIC is the device actually using" heuristic: Up, not Loopback/Tunnel,
/// carries a non-0.0.0.0 default gateway. Fail-soft — any enumeration error yields null.
/// </summary>
internal static class NetworkInterfaceLocator
{
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
internal static NetworkInterface? FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
/// <summary>First IPv4 default gateway of the interface (0.0.0.0 excluded), or null.</summary>
internal static IPAddress? GetIpv4Gateway(NetworkInterface nic)
{
try
{
foreach (var gw in nic.GetIPProperties().GatewayAddresses)
{
var address = gw?.Address;
if (address != null
&& address.AddressFamily == AddressFamily.InterNetwork
&& address.ToString() != "0.0.0.0")
{
return address;
}
}
}
catch
{
// Caller handles null
}
return null;
}
}
}
@@ -10,7 +10,9 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -43,10 +45,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public string SasUrlPrefix { get; set; }
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
/// <summary>Human-readable error code/message when upload failed or was skipped. Null on success.</summary>
public string ErrorCode { get; set; }
/// <summary>
/// True when no package was built because a configuration gate said so (mode=Off,
/// destination not configured, OnFailure + known success). <see cref="ErrorCode"/>
/// then carries the machine-readable gate name so the emitted
/// <c>server_action_failed</c> / <c>diagnostics_upload_failed</c> event names the
/// actual reason instead of a generic "upload failed".
/// </summary>
public bool Skipped { get; set; }
public bool Success => BlobName != null;
public const string SkipModeOff = "diagnostics_skipped_mode_off";
public const string SkipNotConfigured = "diagnostics_skipped_not_configured";
public const string SkipOnFailureSucceeded = "diagnostics_skipped_onfailure_success";
public static DiagnosticsUploadResult SkippedBy(string gate) =>
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
}
/// <summary>
@@ -124,62 +142,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private readonly string _imeLogFolder;
private readonly string _agentLogFolder;
private readonly string _agentStateFolder;
private readonly string _agentSpoolFolder;
private readonly string _agentDataFolder;
// Built-in sections come from the Shared catalog (DiagnosticsBuiltInSections.All) — the
// same list the backend serves to the portal, so what administrators see is exactly
// what this service collects. Test seam: section id → folder; production leaves the
// map empty and every section resolves from the catalog's (unexpanded) source folder.
private readonly IReadOnlyDictionary<string, string> _sectionFolderOverrides;
private static readonly string DefaultImeLogFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
private static readonly string DefaultAgentLogFolder =
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
private static readonly string DefaultAgentStateFolder =
Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
private static readonly string DefaultAgentSpoolFolder =
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory);
private static readonly string DefaultAgentDataFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
/// <summary>
/// File extensions collected from built-in log folders (Agent + IME).
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
/// </summary>
private static readonly string[] LogFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
};
/// <summary>
/// Patterns collected from the agent state folder. Adds completion markers on top of
/// the standard log patterns so files like <c>enrollment-complete.marker</c> and
/// <c>whiteglove-backfill-state.json</c> reach the diagnostics archive.
/// </summary>
private static readonly string[] StateFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
"*.complete", "*.marker"
};
/// <summary>
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
/// completion/exit markers, no session.id / bootstrap.json (those are config, not
/// forensic state).
/// </summary>
private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" };
/// <summary>
/// Telemetry spool: only the JSON-shaped files. The spool folder may also contain
/// quarantine subfolders, so subfolders are excluded to keep the archive focused
/// on what is actually pending upload.
/// </summary>
private static readonly string[] SpoolFilePatterns = new[] { "*.jsonl", "*.json" };
// Scenario oracle for DiagnosticsSectionCondition.DevicePreparation. Production reads
// the deterministic WDP registry marker (the same gate every other WDP switch uses);
// tests inject a constant so they never touch the registry.
private readonly Func<bool> _devicePreparationProbe;
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
: this(configuration, logger, apiClient, null, null, null, null, null)
@@ -188,7 +160,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Test seam: allows xUnit fixtures to redirect log/state/spool/data folders to a
// temp dir without touching real %ProgramData% paths. Production callers always go
// through the public ctor.
// through the public ctor. The five named overrides map onto the catalog's section
// ids; sectionFolderOverrides covers every other section (RealmJoin*, ImeBootstrapper…).
internal DiagnosticsPackageService(
AgentConfiguration configuration,
AgentLogger logger,
@@ -197,24 +170,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
string imeLogFolderOverride,
string agentStateFolderOverride,
string agentSpoolFolderOverride,
string agentDataFolderOverride)
string agentDataFolderOverride,
IReadOnlyDictionary<string, string> sectionFolderOverrides = null,
Func<bool> devicePreparationProbe = null)
{
_configuration = configuration;
_logger = logger;
_apiClient = apiClient;
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
_agentLogFolder = agentLogFolderOverride ?? DefaultAgentLogFolder;
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
_agentStateFolder = agentStateFolderOverride ?? DefaultAgentStateFolder;
_agentSpoolFolder = agentSpoolFolderOverride ?? DefaultAgentSpoolFolder;
_agentDataFolder = agentDataFolderOverride ?? DefaultAgentDataFolder;
var overrides = new Dictionary<string, string>(StringComparer.Ordinal);
void Map(string sectionId, string folder)
{
if (folder != null) overrides[sectionId] = folder;
}
Map("AgentLogs", agentLogFolderOverride);
Map("ImeLogs", imeLogFolderOverride);
Map("AgentState", agentStateFolderOverride);
Map("AgentSpool", agentSpoolFolderOverride);
Map("AgentMarkers", agentDataFolderOverride);
if (sectionFolderOverrides != null)
{
foreach (var kv in sectionFolderOverrides)
overrides[kv.Key] = kv.Value;
}
_sectionFolderOverrides = overrides;
// Tests that pass folder overrides but no probe must stay registry-free: a seam
// caller gets "not WDP" by default, the production ctor gets the real marker.
_devicePreparationProbe = devicePreparationProbe
?? (overrides.Count == 0
? (Func<bool>)EnrollmentRegistryDetector.IsDeterministicDevicePreparation
: () => false);
}
/// <summary>
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
/// A configuration gate (mode=Off, not configured, OnFailure+succeeded) returns a result
/// with <see cref="DiagnosticsUploadResult.Skipped"/> and the gate name in ErrorCode.
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
/// <param name="enrollmentSucceeded">
@@ -237,14 +231,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
{
_logger.Debug("Diagnostics upload disabled (mode=Off)");
return null;
_logger.Info("Diagnostics upload skipped: DiagnosticsUploadMode=Off on the running agent.");
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
}
if (!_configuration.DiagnosticsUploadEnabled)
{
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
return null;
_logger.Info($"Diagnostics upload skipped: no upload destination configured for this tenant (mode={mode}).");
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipNotConfigured);
}
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
@@ -252,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return null;
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipOnFailureSucceeded);
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
@@ -370,33 +364,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// 1. sessioninfo.txt — informational metadata, not subject to caps.
AddSessionInfo(archive, enrollmentSucceeded);
// 2. Agent logs
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, _agentLogFolder, "AgentLogs", pattern, tracker);
// 2. Built-in sections — ONE catalog shared with the backend (the portal
// shows exactly this list): agent logs, IME logs (+ the Device Preparation
// bootstrapper event log on WDP), agent state (recursive: `.quarantine`
// and `.part1-<utc>/` buckets ride along), telemetry spool, top-level
// markers, and the RealmJoin logs behind the tenant's watcher toggle.
// The scenario probe is a registry read — evaluate it once per build.
var isDevicePreparation = ProbeDevicePreparation();
ManifestLine($"SCENARIO: devicePreparation={isDevicePreparation} realmJoinWatcher={_configuration.EnableRealmJoinWatcher}");
foreach (var section in DiagnosticsBuiltInSections.All)
AddBuiltInSection(archive, section, tracker, isDevicePreparation);
// 3. IME logs (all relevant files in the IME log folder)
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, _imeLogFolder, "ImeLogs", pattern, tracker);
// 4. Agent state: snapshot, journal, signal-log, ime-tracker-state,
// enrollment-complete.marker, final-status.json, whiteglove-backfill-state.json.
// Recursive to include `.quarantine` subfolders AND `.part1-<utc>/` buckets
// that StateArchiver creates on WG Part-2 boot — both ride along under
// AgentState/ via the same recursive walk.
foreach (var pattern in StateFilePatterns)
AddLogFiles(archive, _agentStateFolder, "AgentState", pattern, tracker, includeSubfolders: true);
// 5. Telemetry spool — pending uploads + upload cursor (forensic: what didn't ship?)
foreach (var pattern in SpoolFilePatterns)
AddLogFiles(archive, _agentSpoolFolder, "AgentSpool", pattern, tracker, includeSubfolders: false);
// 6. Top-level markers (whiteglove.complete, clean-exit marker) — Part-1/Part-2
// bridge + ghost-restart guard. Top-directory only to avoid re-zipping
// State/Spool/Logs/Agent which already have their own sections.
foreach (var pattern in RootMarkerPatterns)
AddLogFiles(archive, _agentDataFolder, "AgentMarkers", pattern, tracker, includeSubfolders: false);
// 7. Configured additional log paths (global + tenant, validated by guards)
// 3. Configured additional log paths (global + tenant, validated by guards)
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
{
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
@@ -447,6 +426,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private bool ProbeDevicePreparation()
{
try
{
return _devicePreparationProbe();
}
catch (Exception ex)
{
// Fail toward Classic like every other WDP gate: the evtx export is skipped,
// never the whole package.
_logger.Warning($"Device Preparation probe failed — treating as Classic: {ex.Message}");
ManifestLine($"SCENARIO PROBE FAILED (treated as Classic): {ex.Message}");
return false;
}
}
/// <summary>
/// Pure gate: is this built-in section collected under the given configuration and
/// enrollment scenario? Unknown conditions (a newer Shared catalog than this agent)
/// fail closed.
/// </summary>
internal static bool IsSectionActive(DiagnosticsBuiltInSection section, AgentConfiguration configuration, bool isDevicePreparation)
{
switch (section.Condition)
{
case DiagnosticsSectionCondition.Always:
return true;
case DiagnosticsSectionCondition.RealmJoinWatcher:
return configuration.EnableRealmJoinWatcher;
case DiagnosticsSectionCondition.DevicePreparation:
return isDevicePreparation;
default:
return false;
}
}
private static string DescribeInactiveCondition(DiagnosticsSectionCondition condition)
{
switch (condition)
{
case DiagnosticsSectionCondition.RealmJoinWatcher:
return "RealmJoin Watcher disabled";
case DiagnosticsSectionCondition.DevicePreparation:
return "not a Device Preparation enrollment";
default:
return $"unknown condition {condition}";
}
}
private void AddBuiltInSection(ZipArchive archive, DiagnosticsBuiltInSection section, BudgetTracker tracker, bool isDevicePreparation)
{
if (!IsSectionActive(section, _configuration, isDevicePreparation))
{
ManifestLine($"BUILT-IN SKIPPED ({DescribeInactiveCondition(section.Condition)}): {section.Id}");
return;
}
// Override (test seam) or catalog source folder with %ProgramData% /
// %LOGGED_ON_USER_PROFILE% expanded; null means the token is present but no
// interactive user has been detected yet — same skip as configured paths.
var folder = _sectionFolderOverrides.TryGetValue(section.Id, out var overrideFolder)
? overrideFolder
: UserProfileResolver.ExpandCustomTokens(section.SourceFolder);
if (folder == null)
{
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
return;
}
ManifestLine($"BUILT-IN: {section.Id} -> folder='{folder}' zip='{section.ZipFolder}' recursive={section.IncludeSubfolders}");
foreach (var pattern in section.Patterns)
AddLogFiles(archive, folder, section.ZipFolder, pattern, tracker, section.IncludeSubfolders);
}
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
{
var sb = new StringBuilder();
@@ -20,7 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public static class UserProfileResolver
{
public const string Token = "%LOGGED_ON_USER_PROFILE%";
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
private static readonly object Lock = new object();
private static bool _resolved;
@@ -922,54 +922,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static NetworkInterface FindActiveNetworkInterface() =>
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
/// <summary>
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
/// VMs without WiFi service simply get no event.
/// Collects WiFi signal info via <see cref="WifiInfoProvider"/> (native WLAN API → WinRT
/// SSID → netsh, all language-neutral bar the last) and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run — never blocks
/// other collection. VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
{
try
{
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
if (wifi == null)
return;
// Warning, not Debug: the caller only reaches here when the active NIC IS
// WiFi, so a silent miss is a real gap — and Debug is off at the default
// log level, which is what made this invisible in the field.
_logger.Warning($"EnrollmentTracker: no wifi_signal_info — {wifiDiag}");
return;
}
var data = new Dictionary<string, object>();
@@ -981,12 +954,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
data["wifiRadioType"] = wifi.RadioType;
if (wifi.Channel.HasValue)
data["wifiChannel"] = wifi.Channel.Value;
if (wifi.DataLimitedReason != null)
data["wifiDataLimitedReason"] = wifi.DataLimitedReason;
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
if (wifi.SignalPercent.HasValue)
message += $", Signal: {wifi.SignalPercent.Value}%";
if (wifi.RadioType != null)
message += $" ({wifi.RadioType})";
// Say it in the timeline too — a missing signal reads like a bug otherwise, and
// this is the only place an admin ever learns that a privacy setting caused it.
if (wifi.DataLimitedReason == WifiInfoProvider.LocationServicesOff)
message += " — signal unavailable, Location services off";
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
@@ -390,23 +390,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
/// Synchronous — needed before emitting the event.
/// Collects WiFi SSID, signal, and radio type via <see cref="WifiInfoProvider"/>
/// (native WLAN API → WinRT SSID → netsh). Synchronous — needed before emitting the event.
/// </summary>
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
{
try
{
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
}
var wifi = WifiInfoProvider.TryRead(interfaceGuid, out var wifiDiag);
if (wifi != null)
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
// Warning, not Debug: reached only when the active NIC IS WiFi, and Debug is off
// at the default log level — which is what hid this failure in the field.
_logger.Warning($"NetworkChangeDetector: no WiFi info for the active WLAN adapter — {wifiDiag}");
}
catch (Exception ex)
{
@@ -420,36 +417,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Helpers
// -------------------------------------------------------------------
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static NetworkInterface FindActiveNetworkInterface() =>
NetworkInterfaceLocator.FindActiveNetworkInterface();
private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after)
{
@@ -326,10 +326,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
endpointUnavailable: true);
}
// Backend JSON bodies may carry a machine-readable errorCode (Shared
// Constants.AgentErrorCodes) that tells the caller how to react — e.g.
// session_owner_mismatch → rotate the session id instead of treating this as a
// certificate rejection. Absent/unparseable → null, classic auth failure.
var errorCode = TryExtractErrorCode(body);
throw new BackendAuthException(
$"Backend returned {statusText}. " +
"The device is not authorized. Check client certificate and Autopilot device validation.",
(int)response.StatusCode);
(int)response.StatusCode,
errorCode: errorCode);
}
/// <summary>
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
/// or a non-string value yields null, never an exception.
/// </summary>
internal static string TryExtractErrorCode(string body)
{
if (string.IsNullOrWhiteSpace(body) || body.TrimStart().Length == 0 || body.TrimStart()[0] != '{')
return null;
try
{
var obj = Newtonsoft.Json.Linq.JObject.Parse(body);
var token = obj["errorCode"] ?? obj["ErrorCode"];
if (token == null || token.Type != Newtonsoft.Json.Linq.JTokenType.String)
return null;
var value = (string)token;
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
catch
{
return null;
}
}
/// <summary>
@@ -429,10 +458,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// </summary>
public bool EndpointUnavailable { get; }
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
/// <summary>
/// Machine-readable code from the backend's JSON error body (see Shared
/// <c>Constants.AgentErrorCodes</c>), or null when the body carried none.
/// </summary>
public string ErrorCode { get; }
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false, string errorCode = null) : base(message)
{
StatusCode = statusCode;
EndpointUnavailable = endpointUnavailable;
ErrorCode = errorCode;
}
}
}
@@ -0,0 +1,217 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
public class DoGroupIdSetResult
{
/// <summary>The DOGroupId value is the computed one after this run (written or already there).</summary>
public bool Success { get; set; }
/// <summary>True when this run actually wrote the registry value (false for the idempotent no-op).</summary>
public bool Written { get; set; }
/// <summary>Deliberate non-action (existing foreign policy/value, no usable network) — not an error.</summary>
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? GroupId { get; set; }
public string? PreviousValue { get; set; }
public string? GatewayIp { get; set; }
public string? GatewayMac { get; set; }
public string? Error { get; set; }
}
internal enum DoGroupIdAction
{
Write,
NoOp,
SkipForeign,
}
/// <summary>
/// Sets the Delivery Optimization group ID policy value (<c>DOGroupId</c> under the GPO
/// DeliveryOptimization key) from a network fingerprint: a deterministic GUID derived from the
/// default gateway's IPv4 address and MAC address, so all devices behind the same gateway land
/// in the same DO peering group. The byte layout is RealmJoin-compatible — devices managed by
/// RealmJoin on the same network compute the identical GUID and peer with ours.
/// <para>
/// Deliberately conservative where RealmJoin is not: no random-GUID fallback (a per-device
/// random ID would isolate the device in a one-member group — worse than the OS default
/// grouping), and existing foreign configuration always wins: a DOGroupIdSource policy, an
/// MDM-managed DOGroupId, or a GPO value we did not write ourselves all result in a skip.
/// Never throws.
/// </para>
/// </summary>
public static class DoGroupIdService
{
internal const string GroupIdValueName = "DOGroupId";
internal const string GroupIdSourceValueName = "DOGroupIdSource";
// RealmJoin's fingerprint-GUID prefix — part of the compatible byte layout, do not change.
private static readonly byte[] GuidPrefix = { 101, 48, 6 };
/// <summary>
/// Computes and applies the fingerprint GroupId. <paramref name="lastWrittenGroupId"/> is
/// the GUID a previous agent run wrote (null when none is recorded) — it lets us update our
/// own value after a gateway change while never touching a foreign one.
/// </summary>
public static DoGroupIdSetResult TrySetGroupId(string? lastWrittenGroupId, AgentLogger logger)
{
var result = new DoGroupIdSetResult();
try
{
// ---- Network fingerprint -------------------------------------------------
var nic = NetworkInterfaceLocator.FindActiveNetworkInterface();
var gateway = nic == null ? null : NetworkInterfaceLocator.GetIpv4Gateway(nic);
if (gateway == null)
{
result.Skipped = true;
result.SkipReason = "no_ipv4_gateway";
logger.Info("DO GroupId auto-set: no active interface with an IPv4 default gateway — skipping.");
return result;
}
result.GatewayIp = gateway.ToString();
if (!ArpNativeMethods.TryResolveMac(gateway, out var mac, out var arpError))
{
result.Skipped = true;
result.SkipReason = "arp_failed";
result.Error = arpError;
logger.Warning($"DO GroupId auto-set: could not resolve gateway MAC ({arpError}) — skipping.");
return result;
}
result.GatewayMac = BitConverter.ToString(mac);
var computed = ComputeGroupId(gateway.GetAddressBytes(), mac).ToString();
result.GroupId = computed;
// ---- Foreign-configuration checks ----------------------------------------
// DOGroupIdSource (either store) selects a dynamic group source — a fingerprint
// GroupId would fight it. An MDM-managed DOGroupId means Intune owns the setting.
if (ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdSourceValueName) != null
|| ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdSourceValueName) != null)
{
result.Skipped = true;
result.SkipReason = "group_id_source_policy";
logger.Info("DO GroupId auto-set: DOGroupIdSource policy is configured — skipping.");
return result;
}
if (ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdValueName) != null)
{
result.Skipped = true;
result.SkipReason = "mdm_policy_present";
logger.Info("DO GroupId auto-set: DOGroupId is managed via MDM policy — skipping.");
return result;
}
// ---- Ownership decision ---------------------------------------------------
var existing = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
result.PreviousValue = existing;
switch (DecideAction(existing, computed, lastWrittenGroupId))
{
case DoGroupIdAction.NoOp:
result.Success = true;
logger.Info($"DO GroupId auto-set: already set to {computed} — nothing to do.");
return result;
case DoGroupIdAction.SkipForeign:
result.Skipped = true;
result.SkipReason = "foreign_value_present";
logger.Info($"DO GroupId auto-set: existing DOGroupId '{existing}' was not written by us — skipping.");
return result;
}
// ---- Write + readback -----------------------------------------------------
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = hklm.CreateSubKey(DoThrottlePolicyReader.GpoKeyPath))
{
key.SetValue(GroupIdValueName, computed, RegistryValueKind.String);
}
var readback = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
if (!string.Equals(readback, computed, StringComparison.OrdinalIgnoreCase))
{
result.Error = $"readback mismatch: wrote '{computed}', read '{readback ?? "<null>"}'";
logger.Warning($"DO GroupId auto-set failed: {result.Error}");
return result;
}
result.Success = true;
result.Written = true;
logger.Info($"DO GroupId auto-set: DOGroupId set to {computed} (gateway {result.GatewayIp}, previous: {existing ?? "<none>"})");
return result;
}
catch (Exception ex)
{
result.Error = ex.Message;
logger.Warning($"DO GroupId auto-set failed: {ex.Message}");
return result;
}
}
/// <summary>
/// RealmJoin-compatible fingerprint GUID: 16 bytes laid out as
/// prefix {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac[6], passed to
/// <c>new Guid(byte[])</c>. The IP byte shuffle mirrors RealmJoin's "swap for
/// new Guid()" step — the layout must stay bit-identical for cross-product peering.
/// </summary>
internal static Guid ComputeGroupId(byte[] ipv4Bytes, byte[] macBytes)
{
if (ipv4Bytes == null || ipv4Bytes.Length != 4)
throw new ArgumentException("IPv4 address bytes required", nameof(ipv4Bytes));
if (macBytes == null || macBytes.Length != 6)
throw new ArgumentException("6-byte MAC required", nameof(macBytes));
var bytes = new byte[16];
Array.Copy(GuidPrefix, 0, bytes, 0, GuidPrefix.Length);
// bytes[3..5] stay zero (RealmJoin's zero padding)
bytes[6] = ipv4Bytes[1];
// bytes[7] stays zero
bytes[8] = ipv4Bytes[2];
bytes[9] = ipv4Bytes[3];
Array.Copy(macBytes, 0, bytes, 10, macBytes.Length);
return new Guid(bytes);
}
/// <summary>
/// Pure ownership decision. GUID comparison tolerates case and braces so a value written
/// by another tool in "{...}" form still matches semantically.
/// </summary>
internal static DoGroupIdAction DecideAction(string? existing, string computed, string? lastWrittenByUs)
{
if (string.IsNullOrEmpty(existing))
return DoGroupIdAction.Write;
if (GuidEquals(existing, computed))
return DoGroupIdAction.NoOp;
if (GuidEquals(existing, lastWrittenByUs))
return DoGroupIdAction.Write; // our own stale value — gateway changed
return DoGroupIdAction.SkipForeign;
}
private static bool GuidEquals(string? a, string? b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
return Guid.TryParse(a, out var ga) && Guid.TryParse(b, out var gb) && ga == gb;
}
private static object? ReadPolicyValue(string keyPath, string valueName)
{
// Registry64 explicitly — SOFTWARE\Policies is WOW64-redirected, and the policy
// stores live in the 64-bit view regardless of the agent's bitness.
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = hklm.OpenSubKey(keyPath))
{
return key?.GetValue(valueName);
}
}
}
}
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private const int MinIntervalMinutes = 10;
private readonly BackendApiClient _apiClient;
private readonly string _sessionId;
private volatile string _sessionId;
private readonly string _tenantId;
private readonly string _agentVersion;
private readonly AgentLogger _logger;
@@ -52,6 +52,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
_logger = logger;
}
/// <summary>
/// SESSION-OWNER-BINDING: the reporter is built before registration and snapshots the
/// SessionId; a rotation during registration must be reflected here or later reports
/// would name the refused session.
/// </summary>
public void UpdateSessionId(string sessionId)
{
if (!string.IsNullOrWhiteSpace(sessionId))
_sessionId = sessionId;
}
/// <summary>
/// Attempts to send an emergency report. Returns immediately without awaiting
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
@@ -0,0 +1,38 @@
using System;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Boot-time NIC grace shared by every first-contact backend call (remote-config fetch,
/// session registration, emergency-break reporter pattern): polls the cheap link-level
/// signal once per second for at most <paramref name="maxWait"/>. It cannot prove backend
/// reachability — the callers' retry loops handle that — it only keeps retry budgets from
/// being burned into a link that is provably still down (BootTrigger relaunch after a
/// mid-enrollment reboot, Wi-Fi still associating; tenant aebdce78 audits 2026-08-23/24).
/// Free on the normal path — a live link returns immediately. Probe errors end the wait,
/// never the caller's operation.
/// </summary>
internal static class NetworkLinkWait
{
internal static async Task WaitAsync(AgentLogger logger, TimeSpan maxWait, string context)
{
if (NetworkInterface.GetIsNetworkAvailable()) return;
logger?.Info($"{context}: no network link yet — waiting up to {maxWait.TotalSeconds:F0}s.");
var deadline = DateTime.UtcNow + maxWait;
while (DateTime.UtcNow < deadline)
{
await Task.Delay(1000).ConfigureAwait(false);
if (NetworkInterface.GetIsNetworkAvailable())
{
logger?.Info($"{context}: network link is up.");
return;
}
}
logger?.Warning($"{context}: still no network link after {maxWait.TotalSeconds:F0}s — attempting anyway.");
}
}
}
@@ -88,9 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private int _passThroughStepsSinceSnapshot; // H1a — pass-through steps skipped since last snapshot
private bool _parkedTripwireFired; // liveness PR1 — one-shot per agent run, no state-schema touch
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixes L8: the
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixed L8: the
// tripwire post no longer runs on the ingress worker, so a full channel cannot
// self-deadlock on it. Guarded by _parkedLock because the callback races ApplyStep.
// self-deadlock on it. (Since 2026-08-29 SignalIngress.Post itself is re-entrancy-safe
// — worker-thread posts go to a worker-local queue — so the EffectRunner's
// ClassifierVerdictIssued post from inside ApplyStep is covered too.) Guarded by
// _parkedLock because the callback races ApplyStep.
private readonly object _parkedLock = new object();
private readonly TimeSpan _parkedTripwireDwell;
private Timer? _parkedDwellTimer;
@@ -557,6 +557,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
stateDirectory: _stateDirectory));
}
// System timeline watcher — System channel: Kernel-General 1 (system clock stepped,
// ground truth for time jumps incl. old/new time + setting process) and completed
// sleep/standby episodes (Power-Troubleshooter 1, Kernel-Power 507). Backfill recovers
// the pre-agent OOBE clock correction and standby episodes from the persistent .evtx.
if (collectors.EnableSystemTimelineWatcher)
{
hosts.Add(new SystemTimelineWatcherHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
backfillLookbackMinutes: collectors.SystemEventBackfillLookbackMinutes,
stateDirectory: _stateDirectory));
}
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
// V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
@@ -15,10 +15,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <summary>
/// Live AC/battery watcher: subscribes to the WMI push event
/// <c>Win32_PowerManagementEvent WHERE EventCode = 10</c> (PBT_APMPOWERSTATUSCHANGE — fires on
/// AC↔DC switches and battery-percentage steps; the event carries no payload, so each arrival
/// re-probes via <see cref="PowerStateProbe"/>) and diffs the snapshots through the pure
/// <see cref="PowerStateTransitionTracker"/> into <c>power_state_change</c> events. No polling:
/// between pushes the host is completely idle. Complements the one-shot startup
/// AC↔DC switches and battery-percentage steps), falling back to the intrinsic
/// <c>__InstanceModificationEvent WITHIN 30 … Win32_Battery</c> where the legacy push provider
/// no longer loads (Windows 11 24H2/25H2, builds 26200+: activation fails WBEM_E_NOT_FOUND
/// although the provider is still registered — session 161b838c). Either way the arriving
/// event's payload is ignored; each arrival re-probes via <see cref="PowerStateProbe"/> and
/// diffs the snapshots through the pure <see cref="PowerStateTransitionTracker"/> into
/// <c>power_state_change</c> events. No polling in this process (the fallback's WITHIN
/// sampling runs inside WinMgmt). Complements the one-shot startup
/// <c>power_state_check</c> (<c>StartupEnvironmentProbes</c>), which stays the baseline event.
/// <para>
/// Devices without a battery (or with a failing probe) never arm the WMI subscription — zero
@@ -117,10 +121,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (!armWmi) return;
// Arm chain: the extrinsic push event first (zero WinMgmt cost, still alive on older
// builds), then the intrinsic Win32_Battery modification event. On Windows 11 24H2/25H2
// (observed on builds 26200/26220, session 161b838c) the MS_Power_Management_Event_Provider
// is still REGISTERED but its activation fails with WBEM_E_NOT_FOUND — the legacy provider
// no longer loads, so the push query throws on exactly the devices that enroll today.
// The fallback's WITHIN polling runs inside WinMgmt (a Win32_Battery snapshot every 30 s),
// NOT in this process; the handler path is identical (payload ignored, re-probe + diff),
// worst-case detection latency ≈ 35 s incl. debounce.
if (TryArmWatcher(
"SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10",
"power_management_event (push)", baseline, out var pushError))
{
return;
}
if (TryArmWatcher(
"SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance ISA 'Win32_Battery'",
"battery_instance_poll (WinMgmt WITHIN 30s)", baseline, out var fallbackError))
{
return;
}
_logger.Warning($"[{Name}] could not arm any WMI power watcher — " +
$"push: {Describe(pushError)}; fallback: {Describe(fallbackError)} — " +
"live power-state transitions will not be observed this run");
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed",
fallbackError ?? pushError);
}
private static string Describe(Exception? ex)
=> ex == null ? "n/a" : $"{ex.GetType().Name}: {ex.Message}";
/// <summary>
/// One arm attempt for the given WQL event query, with the full dispose-race dance
/// (ConsoleBypassWatcher pattern). Returns true when the chain should stop: the watcher
/// armed, or disposal won the race (then arming anything would leak a subscription).
/// Returns false with the captured exception so the caller can try the next query.
/// </summary>
private bool TryArmWatcher(string wql, string modeLabel, PowerStateResult baseline, out Exception? error)
{
error = null;
try
{
var query = new WqlEventQuery("SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10");
var watcher = new ManagementEventWatcher(query);
var watcher = new ManagementEventWatcher(new WqlEventQuery(wql));
watcher.EventArrived += OnPowerStatusChange;
lock (_lock)
@@ -130,7 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Dispose won the race against the background start — don't arm.
try { watcher.EventArrived -= OnPowerStatusChange; } catch { }
try { watcher.Dispose(); } catch { }
return;
return true;
}
_watcher = watcher;
}
@@ -151,25 +195,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
try { watcher.EventArrived -= OnPowerStatusChange; } catch { }
try { watcher.Stop(); } catch { }
try { watcher.Dispose(); } catch { }
return;
return true;
}
_logger.Info($"[{Name}] watching Win32_PowerManagementEvent (EventCode 10) — " +
_logger.Info($"[{Name}] armed via {modeLabel} — " +
$"baseline: {(baseline.OnAcPower ? "AC" : "battery")}, {baseline.BatteryPercent?.ToString() ?? "?"}%");
return true;
}
catch (Exception ex)
{
_logger.Warning($"[{Name}] could not arm WMI power watcher ({ex.GetType().Name}: {ex.Message}) — " +
"live power-state transitions will not be observed this run");
error = ex;
_logger.Info($"[{Name}] arm attempt '{modeLabel}' failed ({ex.GetType().Name}: {ex.Message})");
lock (_lock) { _watcher = null; }
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex);
return false;
}
}
private void OnPowerStatusChange(object sender, EventArrivedEventArgs e)
{
// The WMI event carries no usable payload; it only tells us "power status changed".
// Debounce, then re-probe — never call watcher.Stop() from inside this callback.
// Push and fallback events are treated identically: the payload is ignored, the event
// only tells us "power status may have changed". Debounce, then re-probe — never call
// watcher.Stop() from inside this callback.
try { ScheduleDebouncedTick(); }
catch (Exception ex) { _logger.Debug($"[{Name}] power event handler error: {ex.Message}"); }
}
@@ -0,0 +1,68 @@
#nullable enable
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Standalone host for the <see cref="SystemTimelineTracker"/> — watches the <c>System</c>
/// event log for system clock steps (Kernel-General 1) and completed sleep/standby episodes
/// (Power-Troubleshooter 1 / Kernel-Power 507) and forwards them via the shared single-rail
/// ingress. Mirrors <see cref="WindowsUpdateWatcherHost"/>: builds its own
/// <see cref="InformationalEventPost"/> and delegates Start/Stop to the tracker.
/// </summary>
internal sealed class SystemTimelineWatcherHost : ICollectorHost
{
public string Name => "SystemTimelineTracker";
private readonly SystemTimelineTracker _tracker;
private readonly AgentLogger _logger;
private int _disposed;
public SystemTimelineWatcherHost(
string sessionId,
string tenantId,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
int backfillLookbackMinutes,
string stateDirectory)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var post = new InformationalEventPost(ingress, clock, logger);
_tracker = new SystemTimelineTracker(
sessionId: sessionId,
tenantId: tenantId,
post: post,
logger: logger,
backfillEnabled: backfillLookbackMinutes > 0,
backfillLookbackMinutes: backfillLookbackMinutes,
stateDirectory: stateDirectory);
}
public void Start()
{
_tracker.Start();
_logger.Info("SystemTimelineWatcherHost: started.");
}
public void Stop()
{
try { _tracker.Stop(); }
catch (Exception ex) { _logger.Warning($"SystemTimelineWatcherHost: stop failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
Stop();
}
}
}
@@ -61,6 +61,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// detection — the exact scenario the power watcher exists to observe.
SharedConstants.EventTypes.PowerStateChange,
// Same reasoning for the system timeline watcher: a clock step, a wake from standby,
// or a backfilled episode at agent start is environment observation and must not
// reset the idle/stall clocks.
SharedConstants.EventTypes.SystemClockChanged,
SharedConstants.EventTypes.SystemSleepEpisode,
// Agent health / control / transport — not device/enrollment progress.
SharedConstants.EventTypes.CollectorDegraded,
SharedConstants.EventTypes.TelemetryUploadPoisoned,
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// Die Back-Pressure-Emission läuft NICHT durch den Signal-Channel (würde deadlocken).
/// </para>
/// <para>
/// <b>Worker re-entrancy</b>: a <see cref="Post"/> issued ON the worker thread itself
/// (EffectRunner posting a <c>ClassifierVerdictIssued</c> from inside
/// <c>ApplyStep</c>) must never touch the bounded channel — the worker is the channel's
/// only consumer, so a full channel would block the consumer as a producer and freeze
/// the whole pipeline for the rest of the run. Such posts go to a worker-local
/// follow-up queue that the worker drains (ahead of the channel) right after the
/// current item finishes. Same processing path, same ordinal/log/reduce semantics —
/// just never blocking.
/// </para>
/// <para>
/// <b>Shutdown</b>: <see cref="Stop"/> ruft <see cref="BlockingCollection{T}.CompleteAdding"/>,
/// wartet auf das Drain-Ende via <c>Thread.Join</c>. Nach <c>Stop</c> wirft jeder
/// weitere <see cref="Post"/> <see cref="InvalidOperationException"/>.
@@ -79,6 +89,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly object _backPressureLock = new object();
private Thread? _worker;
// Worker-thread-only: signals posted re-entrantly from ApplyStep (EffectRunner
// classifier verdicts). Drained by WorkerLoop after every channel item, before the
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
private long _reentrantPostCount;
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
private int _started; // 0 = not started, 1 = running
private int _stopRequested; // 0 = live, 1 = Stop called
@@ -135,6 +150,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public long PendingSignalCount => Interlocked.Read(ref _unprocessedCount);
/// <summary>
/// Signals that were posted from the worker thread itself and routed through the
/// worker-local follow-up queue instead of the bounded channel. Observability/tests.
/// </summary>
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
/// <summary>
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
@@ -218,6 +239,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// Reicht ein Raw-/Derived-/Synthetic-Signal in den Ingress ein. Thread-safe; darf von
/// beliebig vielen Collector-Threads (und vom <see cref="EffectRunner"/>) parallel
/// gerufen werden. Blockiert, wenn der Channel voll ist — Plan §2.1a Back-Pressure.
/// Ausnahme: Aufrufe vom Worker-Thread selbst blockieren nie (worker-local queue).
/// </summary>
public void Post(
DecisionSignalKind kind,
@@ -228,10 +250,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int kindSchemaVersion = 1,
object? typedPayload = null)
{
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
{
throw new InvalidOperationException("SignalIngress has been stopped.");
}
if (evidence == null)
{
throw new ArgumentNullException(nameof(evidence));
@@ -243,6 +261,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var item = new IngressItem(kind, kindSchemaVersion, occurredAtUtc, sourceOrigin, evidence, payload, typedPayload);
// Worker re-entrancy (see class doc): a post from the worker thread itself goes to
// the worker-local follow-up queue, never into the bounded channel. Checked before
// the stop gate on purpose — while Stop() drains the channel the worker may still
// produce verdicts for the items it is finishing, and those must not be lost.
if (ReferenceEquals(Thread.CurrentThread, _worker))
{
Interlocked.Increment(ref _unprocessedCount);
_workerLocalQueue.Enqueue(item);
Interlocked.Increment(ref _reentrantPostCount);
if (_workerLocalQueue.Count > 1)
{
// One follow-up per step is the expected shape (one RunClassifier effect).
// More means effects are chaining on the worker — worth seeing in the log.
_logger?.Debug($"SignalIngress: worker-local follow-up queue depth={_workerLocalQueue.Count} kind={kind} origin={sourceOrigin}");
}
RaiseSignalPosted(kind, payload);
return;
}
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
{
throw new InvalidOperationException("SignalIngress has been stopped.");
}
// Codex Finding 2 (2026-04-30): bump the pending-counter BEFORE handing the item
// to the channel. Decrement in three places: (a) when TryAdd throws because
// CompleteAdding raced between the post-stop check and the enqueue, (b) when
@@ -353,6 +395,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
foreach (var item in _channel.GetConsumingEnumerable())
{
ProcessItem(item);
DrainWorkerLocalQueue();
}
}
catch (Exception ex)
@@ -366,6 +409,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
/// <summary>
/// Processes signals the worker posted to itself during the previous item (see class
/// doc, worker re-entrancy). Iterative, not recursive: a follow-up that produces
/// another follow-up is simply appended and handled in the same loop.
/// </summary>
private void DrainWorkerLocalQueue()
{
while (_workerLocalQueue.Count > 0)
{
ProcessItem(_workerLocalQueue.Dequeue());
}
}
private void ProcessItem(IngressItem item)
{
// Codex Finding 2 (2026-04-30): match the Interlocked.Increment that Post()
@@ -124,6 +124,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Persistence
}
}
/// <summary>
/// Read-only peek of the key's current fingerprint (possibly restored from a previous
/// agent run), or null when none is recorded. Like <see cref="HasFingerprint"/> this makes
/// NO claim — use it when the fingerprint carries state the caller needs to read back
/// (e.g. the DO GroupId ownership marker).
/// </summary>
public string? GetFingerprint(string key)
{
if (string.IsNullOrEmpty(key)) return null;
lock (_lock)
{
return _entries.TryGetValue(key, out var entry) ? entry.Fingerprint : null;
}
}
/// <summary>Retry-until-success: true when <see cref="MarkSucceeded"/> latched this key in any run.</summary>
public bool AlreadySucceeded(string key)
{
@@ -25,6 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
/// Always emitted — offset &gt;60s is Warning, smaller is Info, failure is Warning.</item>
/// <item><b>power_state_check</b> — AC vs. battery snapshot via Win32 <c>GetSystemPowerStatus</c>.
/// On battery with &lt;80% charge is Warning, otherwise Info, probe failure is Warning.</item>
/// <item><b>do_group_id_auto_set</b> — only when <c>EnableDoGroupIdAutoSet=true</c>. Sets the
/// Delivery Optimization <c>DOGroupId</c> policy value from a network fingerprint via
/// <see cref="DoGroupIdService"/>; skips (Info) rather than fight foreign policy values.</item>
/// </list>
/// <para>
/// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event,
@@ -78,6 +81,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
await RunNtpCheck(configuration, logger, post, gate).ConfigureAwait(false);
}
if (configuration.EnableDoGroupIdAutoSet)
RunDoGroupIdAutoSet(configuration, logger, post, gate);
RunPowerStateCheck(configuration, logger, post);
}
@@ -331,6 +337,99 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
};
}
/// <summary>
/// One-shot DO GroupId apply with change-based restart dedup: the gate fingerprint doubles
/// as the ownership marker ("written:&lt;guid&gt;" records the GUID we last wrote, so a later
/// run can distinguish our own stale value from a foreign one after a gateway change).
/// Unchanged outcomes (same GUID, same skip reason) re-run silently without a duplicate
/// event; errors never claim a fingerprint so each failing start keeps its retry trail.
/// </summary>
private static void RunDoGroupIdAutoSet(
AgentConfiguration configuration,
AgentLogger logger,
Orchestration.InformationalEventPost post,
Persistence.StartupEventGate? gate)
{
try
{
var lastWritten = ExtractLastWrittenGroupId(gate?.GetFingerprint(Constants.EventTypes.DoGroupIdAutoSet));
var result = DoGroupIdService.TrySetGroupId(lastWritten, logger);
var fingerprint = ComputeDoGroupIdFingerprint(result);
if (fingerprint != null && gate != null
&& !gate.ShouldEmit(Constants.EventTypes.DoGroupIdAutoSet, fingerprint))
{
logger.Debug("StartupEnvironmentProbes: DO GroupId outcome unchanged — skipping duplicate event (restart dedup).");
return;
}
SafeEmit(post, logger, BuildDoGroupIdEvent(configuration, result));
if (fingerprint != null)
gate?.MarkEmitted(Constants.EventTypes.DoGroupIdAutoSet);
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: DO GroupId auto-set threw: {ex.Message}");
}
}
internal const string DoGroupIdWrittenFingerprintPrefix = "written:";
/// <summary>
/// Success (written or already correct) fingerprints as "written:&lt;guid&gt;", a skip as
/// "skipped:&lt;reason&gt;"; errors return null so they are never deduped.
/// </summary>
internal static string? ComputeDoGroupIdFingerprint(DoGroupIdSetResult result)
{
if (result.Success) return DoGroupIdWrittenFingerprintPrefix + result.GroupId;
if (result.Skipped) return "skipped:" + result.SkipReason;
return null;
}
/// <summary>Ownership marker readback: the GUID from a "written:" fingerprint, else null.</summary>
internal static string? ExtractLastWrittenGroupId(string? fingerprint) =>
fingerprint != null && fingerprint.StartsWith(DoGroupIdWrittenFingerprintPrefix, StringComparison.Ordinal)
? fingerprint.Substring(DoGroupIdWrittenFingerprintPrefix.Length)
: null;
internal static EnrollmentEvent BuildDoGroupIdEvent(AgentConfiguration configuration, DoGroupIdSetResult result)
{
string message;
if (result.Success && result.Written)
message = $"Delivery Optimization GroupId set to {result.GroupId} (gateway {result.GatewayIp})";
else if (result.Success)
message = $"Delivery Optimization GroupId already set to {result.GroupId}";
else if (result.Skipped)
message = $"Delivery Optimization GroupId not set: {result.SkipReason}";
else
message = $"Delivery Optimization GroupId auto-set failed: {result.Error}";
return new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = Constants.EventTypes.DoGroupIdAutoSet,
Severity = result.Success || result.Skipped ? EventSeverity.Info : EventSeverity.Warning,
Source = "StartupEnvironmentProbes",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = message,
Data = new Dictionary<string, object>
{
{ "groupId", result.GroupId ?? "" },
{ "previousValue", result.PreviousValue ?? "" },
{ "gatewayIp", result.GatewayIp ?? "" },
{ "gatewayMac", result.GatewayMac ?? "" },
{ "written", result.Written },
{ "skipped", result.Skipped },
{ "skipReason", result.SkipReason ?? "" },
{ "success", result.Success },
{ "error", result.Error ?? "" },
},
};
}
private static void RunPowerStateCheck(
AgentConfiguration configuration,
AgentLogger logger,
@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Security;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
@@ -23,6 +24,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <item><description><c>1.2.840.113556.1.5.284.2</c> → DeviceId (same encoding)</description></item>
/// </list>
/// <para>
/// The sibling <c>Microsoft Intune MDM Device CA</c> client cert (the one the agent authenticates
/// with, see <see cref="CertificateHelper"/>) carries a DIFFERENT OID arc that identifies the same
/// device from Intune's side. Observed on a live Intune-enrolled Windows 11 device:
/// </para>
/// <list type="table">
/// <listheader><term>OID</term><description>Meaning / encoding</description></listheader>
/// <item><term><c>1.2.840.113556.5.4</c></term><description>Intune DeviceId — BARE 16-byte GUID, no ASN.1 wrapper. Equals the cert's Subject CN.</description></item>
/// <item><term><c>1.2.840.113556.5.6</c></term><description>Intune AccountId (the Intune tenant account, NOT the Entra TenantId) — OCTET STRING GUID.</description></item>
/// <item><term><c>1.2.840.113556.5.14</c></term><description>Entra TenantId — OCTET STRING GUID. This is the one to compare against an Entra tenant id.</description></item>
/// <item><term><c>1.2.840.113556.5.11</c></term><description>Further GUID, OCTET STRING; semantics not documented by Microsoft and not verified here.</description></item>
/// <item><term><c>1.2.840.113556.5.15/.16/.17/.18/.19/.23</c></term><description>ASN.1 INTEGER enums (platform / enrollment flavour); semantics undocumented.</description></item>
/// </list>
/// <para>
/// The bare-GUID encoding of <c>1.2.840.113556.5.4</c> is why
/// <see cref="ParseGuidFromExtension"/> accepts an unwrapped 16-byte payload as well —
/// a strict OCTET-STRING parser silently returns <c>null</c> for that extension.
/// </para>
/// <para>
/// All probes are best-effort: never throw, return <c>null</c> on miss, log a Warning
/// with the specific failure reason (no matching cert / extension missing / ASN.1
/// parse failed) so the agent log explains why the fallback didn't help.
@@ -31,8 +50,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public static class EntraDeviceCertHelper
{
public const string MsOrganizationAccessIssuer = "MS-Organization-Access";
public const string TenantIdOid = "1.2.840.113556.1.5.284.5";
public const string DeviceIdOid = "1.2.840.113556.1.5.284.2";
public const string TenantIdOid = MsDeviceCertificateOids.EntraJoinCertTenantIdOid;
public const string DeviceIdOid = MsDeviceCertificateOids.EntraJoinCertDeviceIdOid;
/// <summary>
/// Reads the Entra TenantId from the MS-Organization-Access device cert.
@@ -88,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
continue;
}
var guid = ParseGuidFromAsn1OctetString(ext.RawData);
var guid = ParseGuidFromExtension(ext.RawData);
if (guid != null)
{
logger?.Info(
@@ -98,7 +117,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
logger?.Warning(
$"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " +
$"EntraDeviceCertHelper: failed to parse {label} GUID extension in cert thumbprint={cert.Thumbprint} " +
$"(raw {ext.RawData.Length} bytes); trying next candidate.");
}
@@ -116,46 +135,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups,
/// per <see cref="Guid(byte[])"/>) wrapped in an ASN.1 OCTET STRING. Handles both
/// short-form (<c>04 10 ...</c>) and long-form (<c>04 81 10 ...</c>) length encoding.
/// per <see cref="Guid(byte[])"/>) out of a certificate extension's raw value.
/// Two encodings are accepted, because Microsoft uses both:
/// <list type="number">
/// <item><description>Wrapped in an ASN.1 OCTET STRING — short-form (<c>04 10 ...</c>) or
/// long-form (<c>04 81 10 ...</c>) length. Used by the MS-Organization-Access TenantId/DeviceId
/// extensions and by the Intune <c>…5.6</c> / <c>…5.14</c> extensions.</description></item>
/// <item><description>A bare, unwrapped 16-byte payload. Used by the Intune DeviceId extension
/// <c>1.2.840.113556.5.4</c> — verified against a live Intune MDM device cert, whose raw value is
/// exactly 16 bytes with no <c>04 10</c> prefix.</description></item>
/// </list>
/// A valid OCTET-STRING wrapper around 16 bytes is at least 18 bytes long, so the two forms
/// never collide for well-formed input. A 16-byte value is always read as the bare form —
/// a truncated wrapper of exactly that length is indistinguishable from it.
/// Returns <c>null</c> on any structural mismatch — never throws.
/// <para>
/// The decoding itself lives in <see cref="MsDeviceCertificateOids.TryParseGuid"/> so the agent
/// and the backend's client-cert tenant binding share one implementation; this stays as the
/// agent-side nullable-returning façade.
/// </para>
/// </summary>
public static Guid? ParseGuidFromAsn1OctetString(byte[] raw)
{
if (raw == null || raw.Length < 2) return null;
// ASN.1 OCTET STRING tag.
if (raw[0] != 0x04) return null;
int contentStart;
int contentLen;
if (raw[1] < 0x80)
{
// short-form length
contentLen = raw[1];
contentStart = 2;
}
else
{
// long-form length: lower 7 bits = number of following length bytes.
int numLenBytes = raw[1] & 0x7F;
if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null;
contentLen = 0;
for (int i = 0; i < numLenBytes; i++)
contentLen = (contentLen << 8) | raw[2 + i];
contentStart = 2 + numLenBytes;
}
if (contentLen != 16 || contentStart + 16 > raw.Length) return null;
var guidBytes = new byte[16];
Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16);
try { return new Guid(guidBytes); }
catch { return null; }
}
public static Guid? ParseGuidFromExtension(byte[] raw) =>
MsDeviceCertificateOids.TryParseGuid(raw, out var value) ? value : (Guid?)null;
}
}
@@ -175,6 +175,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
}
/// <summary>
/// Replaces the persisted SessionId with a fresh GUID and restarts the session-age clock.
/// Used when the backend answers register-session with <c>session_owner_mismatch</c>
/// (SESSION-OWNER-BINDING): the id on disk belongs to a session bound to a different device
/// identity — the re-enroll-without-wipe shape — so this run must become a new session.
/// Unlike <see cref="Delete"/> this leaves <c>whiteglove.complete</c> untouched: a Part-2
/// resume that has to rotate is still a Part-2 resume.
/// </summary>
/// <returns>The new SessionId.</returns>
public string Rotate(AgentLogger logger = null)
{
lock (_lockObject)
{
var fresh = Guid.NewGuid().ToString();
WriteAtomic(fresh, logger);
SaveSessionCreatedAtInternal(DateTime.UtcNow);
logger?.Info($"SessionIdPersistence: rotated SessionId to {fresh}.");
return fresh;
}
}
private static void TryDelete(string path, AgentLogger logger)
{
try { if (File.Exists(path)) File.Delete(path); }
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Security
@@ -19,12 +20,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <b>Retry contract (V1 <c>MonitoringService.RegisterSessionAsync</c>):</b>
/// </para>
/// <list type="bullet">
/// <item>5 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s).</item>
/// <item>Boot-time NIC grace before the first attempt: when no network link is up yet
/// (BootTrigger relaunch after a mid-enrollment reboot, Wi-Fi still associating) wait
/// up to <see cref="NetworkLinkWaitMax"/> for the link-level signal. Best-effort and
/// free on the normal path — a live link returns immediately.</item>
/// <item>6 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s, 32s —
/// ~62 s of backoff). The original V1 budget of 5 attempts / ~30 s was too short for the
/// reboot-relaunch on Wi-Fi kiosks: the relaunched agent gave up and exited 7 before
/// the network came back, leaving the session silent forever (tenant aebdce78,
/// 2026-08-23 audit). A relaunch that misses here is lost — nothing retries later.</item>
/// <item>On <c>response.Success == true</c> → stop immediately.</item>
/// <item>On <see cref="BackendAuthException"/> (401/403) → report to the
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
/// the shutdown threshold) and return <see cref="SessionRegistrationOutcome.AuthFailed"/>
/// without retrying — the backend has definitively rejected the device cert.</item>
/// <item>Exception to the above — a 403 carrying error code
/// <c>session_owner_mismatch</c> (SESSION-OWNER-BINDING): the persisted SessionId names a
/// session bound to another device identity (typically an Intune re-enrollment without a
/// wipe, where <c>session.id</c> survived but the certificate identity changed). That is
/// not an auth failure: the session is rotated through <c>rotateSession</c> and
/// registration retried exactly once. The tracker is NOT fed — five of these would
/// otherwise soft-shutdown a perfectly authorized agent.</item>
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
/// </list>
@@ -37,16 +53,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// </summary>
public static class SessionRegistrationHelper
{
private const int MaxAttempts = 5;
private const int MaxAttempts = 6;
/// <summary>Upper bound for the pre-registration link wait (see class remarks).</summary>
internal static readonly TimeSpan NetworkLinkWaitMax = TimeSpan.FromSeconds(15);
/// <summary>
/// Executes the 5-retry register-session handshake against the backend. Returns the
/// Executes the retrying register-session handshake against the backend. Returns the
/// response + classified outcome. Never throws.
/// </summary>
/// <param name="backoffDelay">
/// Optional delay-provider used by tests to avoid real-time waits. Production callers
/// pass <c>null</c> → V1 parity <c>Task.Delay(2^attempt * 1000)</c> between attempts.
/// </param>
/// <param name="networkLinkWait">
/// Optional pre-registration link wait used by tests. Production callers pass
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
/// </param>
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
BackendApiClient apiClient,
AgentConfiguration agentConfig,
@@ -56,16 +79,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
EmergencyReporter emergencyReporter = null,
Func<int, Task> backoffDelay = null,
Func<Exception, Task> onTerminalTransportFailure = null,
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null)
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
Func<AgentLogger, Task> networkLinkWait = null,
Func<string> rotateSession = null)
{
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (logger == null) throw new ArgumentNullException(nameof(logger));
backoffDelay ??= DefaultBackoffDelay;
networkLinkWait ??= WaitForNetworkLinkAsync;
var registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
try { await networkLinkWait(logger).ConfigureAwait(false); }
catch (Exception ex) { logger.Debug($"Pre-registration network wait failed ({ex.Message}) — registering anyway."); }
string lastError = null;
Exception lastException = null;
string rotatedFromSessionId = null;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
@@ -77,12 +107,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (response != null && response.Success)
{
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
return SessionRegistrationResult.Succeeded(response);
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
}
lastError = response?.Message ?? "(null response)";
logger.Warning($"Session registration failed: {lastError}");
}
catch (BackendAuthException ex) when (
ex.ErrorCode == Constants.AgentErrorCodes.SessionOwnerMismatch
&& rotateSession != null
&& rotatedFromSessionId == null)
{
// SESSION-OWNER-BINDING: the id on disk belongs to a session this device
// identity does not own. Become a new session and register once more —
// immediately, no backoff, no auth-failure bookkeeping.
rotatedFromSessionId = registration.SessionId;
string newSessionId;
try
{
newSessionId = rotateSession();
}
catch (Exception rotateEx)
{
logger.Error($"Session owner mismatch reported by backend but session rotation failed: {rotateEx.Message}", rotateEx);
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
}
logger.Warning(
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
$"is bound to another device identity (re-enrollment without wipe?). Rotated to {newSessionId}; re-registering.");
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
catch (BackendAuthException ex)
{
logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
@@ -113,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (attempt < MaxAttempts)
{
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16, 32
logger.Info($"Retrying session registration in {delaySeconds}s");
await backoffDelay(attempt).ConfigureAwait(false);
}
@@ -143,6 +199,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private static Task DefaultBackoffDelay(int attempt)
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
/// <summary>
/// Boot-time NIC grace before the first registration attempt — shared polling loop in
/// <see cref="NetworkLinkWait"/>, bounded by <see cref="NetworkLinkWaitMax"/>. Probe
/// errors end the wait, never the registration.
/// </summary>
internal static Task WaitForNetworkLinkAsync(AgentLogger logger)
=> NetworkLinkWait.WaitAsync(logger, NetworkLinkWaitMax, "Session registration");
private static SessionRegistration BuildRegistration(
AgentConfiguration agentConfig,
string agentVersion,
@@ -194,7 +258,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>401/403 from the backend — cert/token rejected. No retry was attempted.</summary>
AuthFailed = 1,
/// <summary>All 5 attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
Failed = 2,
}
@@ -209,20 +273,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public string AdminAction => Response?.AdminAction;
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
/// <summary>
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
/// registration. Null on the normal path.
/// </summary>
public string RotatedFromSessionId { get; }
private SessionRegistrationResult(
SessionRegistrationOutcome outcome,
RegisterSessionResponse response,
int httpStatusCode,
string errorMessage)
string errorMessage,
string rotatedFromSessionId = null)
{
Outcome = outcome;
Response = response;
HttpStatusCode = httpStatusCode;
ErrorMessage = errorMessage;
RotatedFromSessionId = rotatedFromSessionId;
}
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null);
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
public static SessionRegistrationResult AuthFailed(int statusCode, string message)
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message);
@@ -56,6 +56,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
private const string SourceLabel = "ImeLogTracker";
/// <summary>Timeline label: IME wrote its "Completed user session" line and no required user-scope app was pending.</summary>
internal const string EvidenceUserSessionCompleted = "user_session_completed";
/// <summary>Timeline label: IME reported zero targeted apps for the user session, so it never wrote a
/// "Completed user session" line at all. The user phase had nothing to enforce.</summary>
internal const string EvidenceZeroUserApps = "zero_user_apps_enumerated";
private readonly ImeLogTracker _tracker;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
@@ -591,9 +598,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
// Which observation carried the completion. The zero-app variant matters to whoever
// reads the timeline later: the verdict then rests on IME reporting that this user
// had no targeted apps at all, not on watching enforcement finish.
var zeroApps = _tracker.UserSessionZeroAppsObserved;
var evidenceLabel = zeroApps ? EvidenceZeroUserApps : EvidenceUserSessionCompleted;
var summary = zeroApps
? "IME enumerated the user session with zero targeted apps"
: "IME user session completed (no required user-scope apps pending)";
var message = zeroApps
? "IME enumerated the user session with zero targeted apps - no user-phase enforcement to wait for"
: "IME user session completed";
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectionSource"] = "IME log pattern (UserSessionCompleted)",
["detectionSource"] = zeroApps
? "IME log pattern (UserSessionZeroApps)"
: "IME log pattern (UserSessionCompleted)",
["evidence"] = evidenceLabel,
};
TagDerivedTimestamp(derivationInputs, derivedFromClock, rawSourceTs);
@@ -604,12 +626,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "ime-log-tracker-v1",
summary: "IME user session completed (no required user-scope apps pending)",
summary: summary,
derivationInputs: derivationInputs));
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectedAt"] = now.ToString("o"),
["evidence"] = evidenceLabel,
};
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId))
@@ -619,12 +642,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_post.Emit(
eventType: SharedEventTypes.ImeUserSessionCompleted,
source: SourceLabel,
message: "IME user session completed",
message: message,
data: data,
occurredAtUtc: now);
// PR3-D4: terminal-ish lifecycle marker — INFO so it's visible at the default level.
_logger?.Info($"ImeAdapter: user session completed -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
_logger?.Info($"ImeAdapter: user session completed ({evidenceLabel}) -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
}
/// <summary>
+12 -1
View File
@@ -269,11 +269,22 @@ namespace AutopilotMonitor.Agent.V2
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
// Exit codes: 6 = AuthFailed, 7 = anything else. See Runtime/BackendSessionRegistration.cs.
var registration = Runtime.BackendSessionRegistration.Register(
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger);
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
bootstrap.AgentConfig, bootstrap.SessionPersistence, auth, transportDir, logger));
if (registration.ShouldExit)
{
return registration.ExitCode;
}
// Phase 6.5 (config self-heal): when the Phase 4 fetch ran before the network link
// was up, registration has just proven the backend reachable — retry the fetch once
// so the session runs on the live tenant config instead of cache/defaults.
// See AgentRuntimeConfig.TryRecoverAfterRegistration for what is deliberately
// NOT re-run here (kill-signal check, endpoint migration).
Runtime.AgentRuntimeConfig.TryRecoverAfterRegistration(
bootstrap.AgentConfig, auth, runtimeConfig, GetAgentVersion(), consoleMode, logger);
// Phase 7+8 (orchestrator + shutdown lifecycle + onIngressReady wiring +
// initial signal posts + analyzer/probes startup + shutdown.Wait + finally)
// is encapsulated in AgentRuntimeHost. See Runtime/AgentRuntimeHost.cs.
@@ -69,6 +69,64 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
var configMergeResult = ApplyFetchedConfig(agentConfig, remoteConfig, auth, agentVersion, consoleMode, logger);
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
}
/// <summary>
/// Phase 6.5 — config self-heal. When the Phase 4 fetch ran before the network link was
/// up (agent relaunched at boot, Wi-Fi still associating — tenant aebdce78 audit 2026-08-24:
/// ~50% of sessions ran on ConfigVersion=0 exactly this way), a successful session
/// registration has just proven the backend reachable. Retry the fetch once and apply
/// the live tenant config instead of stranding the whole session on cache/defaults.
/// <para>
/// Deliberately NOT re-run here: the kill-signal check (post-registration kills arrive
/// via the telemetry-channel control signals on every upload response) and endpoint
/// migration (registration already succeeded against the current base URL; a migration
/// signal is honoured at the next agent start). Never throws — recovery is strictly
/// best-effort on top of an already-degraded-but-working fallback.
/// </para>
/// </summary>
public static void TryRecoverAfterRegistration(
AgentConfiguration agentConfig,
BackendAuthBundle auth,
RuntimeConfigBundle bundle,
string agentVersion,
bool consoleMode,
AgentLogger logger)
{
try
{
var service = bundle?.RemoteConfigService;
if (service == null) return;
var recovered = service.TryRecoverConfigAsync().GetAwaiter().GetResult();
if (recovered == null) return;
var mergeResult = ApplyFetchedConfig(agentConfig, recovered, auth, agentVersion, consoleMode, logger);
bundle.ApplyRecovered(recovered, mergeResult);
}
catch (Exception ex)
{
logger.Warning($"Post-registration config recovery failed: {ex.Message}");
}
}
/// <summary>
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path,
/// the post-registration recovery path and the live <c>rotate_config</c> ServerAction
/// (<see cref="ServerControlPlane.BuildDispatcher"/>) so all three apply identical semantics.
/// </summary>
internal static RemoteConfigMergeResult ApplyFetchedConfig(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
BackendAuthBundle auth,
string agentVersion,
bool consoleMode,
AgentLogger logger)
{
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
@@ -91,7 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
return configMergeResult;
}
/// <summary>
@@ -175,10 +233,21 @@ namespace AutopilotMonitor.Agent.V2.Runtime
internal sealed class RuntimeConfigBundle
{
public RemoteConfigService RemoteConfigService { get; }
public AgentConfigResponse RemoteConfig { get; }
public RemoteConfigMergeResult MergeResult { get; }
public AgentConfigResponse RemoteConfig { get; private set; }
public RemoteConfigMergeResult MergeResult { get; private set; }
public string PendingMigrationTarget { get; }
/// <summary>
/// Swaps in the config recovered by <see cref="AgentRuntimeConfig.TryRecoverAfterRegistration"/>
/// so Phase 7+8 (orchestrator, collectors, gather rules, IME patterns, the
/// unrestricted-mode audit) run on the live tenant config instead of the fallback.
/// </summary>
public void ApplyRecovered(AgentConfigResponse recoveredConfig, RemoteConfigMergeResult mergeResult)
{
RemoteConfig = recoveredConfig;
MergeResult = mergeResult;
}
public RuntimeConfigBundle(
RemoteConfigService remoteConfigService,
AgentConfigResponse remoteConfig,
@@ -344,7 +344,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
orchestrator.Start(ingress =>
{
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger);
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
rotatedFromSessionId: registrationResult?.RotatedFromSessionId);
// Wire-visible signal that the agent is running on defaults/cache
// rather than the live tenant config — closes the cold-start
// blind spot observed in session 8f2bef72 (2026-05-19).
@@ -420,6 +421,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
terminationHandler: terminationHandler,
remoteConfigService: remoteConfigService,
diagnosticsService: diagnosticsService,
auth: auth,
agentVersion: agentVersion,
consoleMode: consoleMode,
shutdown: shutdown,
shutdownComplete: shutdownComplete,
post: lifecyclePost,
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{
/// <summary>
/// Phase 6 of <see cref="Program"/>'s <c>RunAgent</c>: blocking
/// <c>POST /api/agent/register-session</c> with 5-retry exponential backoff before the
/// <c>POST /api/agent/register-session</c> with link-wait + 6-retry exponential backoff before the
/// orchestrator starts. V1 parity (MonitoringService.RegisterSessionAsync) — without
/// this call the backend's Sessions table never gets a row for this session, so
/// <c>IncrementSessionEventCountAsync</c> / <c>UpdateSessionStatusAsync</c> silently
@@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
HttpClient mtlsHttpClient,
string agentVersion,
bool consoleMode,
AgentLogger logger)
AgentLogger logger,
Func<string> rotateSession = null)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
@@ -43,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger),
// Reuse the single hardened hardware read from the auth bundle so the session row
// matches the security headers the backend validated against (no second WMI query).
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber))
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
rotateSession: rotateSession)
.GetAwaiter().GetResult();
if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded)
@@ -63,6 +65,48 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return SessionRegistrationOutcomeResult.Continue(registrationResult);
}
/// <summary>
/// SESSION-OWNER-BINDING: the one place that turns "backend says this session id is not
/// ours" into a new session. Rotates the persisted id, points the runtime config at it,
/// drops the transport spool (its lines carry the refused session's partition key and
/// would otherwise be uploaded against it after the rotation) and re-targets the
/// emergency reporter, which snapshotted the id before registration. Everything else
/// reads <c>agentConfig.SessionId</c> live after Phase 6.
/// </summary>
internal static string RotateSession(
AgentConfiguration agentConfig,
SessionIdPersistence sessionPersistence,
BackendAuthBundle auth,
string transportDir,
AgentLogger logger)
{
var newSessionId = sessionPersistence.Rotate(logger);
agentConfig.SessionId = newSessionId;
auth?.EmergencyReporter?.UpdateSessionId(newSessionId);
DropTransportSpool(transportDir, logger);
return newSessionId;
}
private static void DropTransportSpool(string transportDir, AgentLogger logger)
{
if (string.IsNullOrWhiteSpace(transportDir)) return;
foreach (var name in new[] { "spool.jsonl", "upload-cursor.json" })
{
var path = System.IO.Path.Combine(transportDir, name);
try
{
if (!System.IO.File.Exists(path)) continue;
var size = new System.IO.FileInfo(path).Length;
System.IO.File.Delete(path);
logger.Info($"Session rotation: dropped {name} ({size} bytes) — its items belonged to the refused session.");
}
catch (Exception ex)
{
logger.Warning($"Session rotation: could not drop {name}: {ex.Message}");
}
}
}
/// <summary>
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
/// Detects whether the terminal exception was a TLS-layer <c>SecureChannelFailure</c>

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