Compare commits

...

46 Commits

Author SHA1 Message Date
Oliver Kieselbach d0ab3eee40 WDP hardening: sticky-v2 EnrollmentType + DevicePreparation downgrade guards
Two low-risk fixes ahead of the first live Device Preparation enrollment:

- StoreSessionAsync: EnrollmentType is now sticky-v2 across re-registration
  (same contract as the sticky-true booleans) so a degraded AutopilotSettings
  read after a reboot can no longer flip an observed v2 session back to v1.
- EnrollmentScenarioProfileUpdater: ImeUserSessionCompleted,
  SelfDeployingDeadlineConfirmed and WhiteGloveSealingConfirmed no longer
  overwrite an established DevicePreparation profile (seeded at Medium from
  the registry-deterministic v2 fact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:55:19 +02:00
Oliver Kieselbach 2de8f5c4e6 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-17 09:35:54 +02:00
Oliver Kieselbach fe07f5cef0 command update 2026-08-17 09:35:07 +02:00
Oliver Kieselbach 57775d6ba2 mcp: errorMessage in tool_call telemetry + get_rule_stats trend opt-in
Telemetry follow-ups from the 7d tool-quality review:
- tool_call log lines now carry a capped errorMessage (thrown message or
  leading toolError text) so error drilldowns name the failure class
  instead of guessing from args; absent on success.
- get_rule_stats omits per-rule daily trend rows by default (they drove
  50% of calls over the response cap at platform scope); new
  includeTrends arg opts back in, trendsNote marks the omission.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:34:03 +02:00
Claude 91b702e239 Fix squeezed Active Users header on mobile
The presence page header was a non-wrapping `flex justify-between`, so on a
phone viewport the title column was compressed to a few characters wide and
"Active Users" plus its description broke one or two words per line next to
the Window/Refresh controls.

Stack the title and controls vertically below the `sm` breakpoint and keep
the existing side-by-side row from `sm` up. The controls also wrap among
themselves on very narrow screens.

The user table had the same problem: its card used `overflow-hidden`, which
clipped the Tenant and Last seen columns off-screen with no way to reach
them. Wrap the table in the repo's usual `overflow-x-auto` container so it
scrolls inside its card, keep cells on one line, and trim cell padding to
`px-4` on mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwjVj2PvpVRk7QUjQf3i8C
2026-08-16 13:19:10 +00:00
Oliver Kieselbach e12da0c99b geo metrics: median API latency as display statistic
A single corrupt session average (request spanning sleep/hibernate counts
wall-clock hours) inflated the request-weighted location mean by orders of
magnitude (Amersfoort: 73286 ms from one 1.38M-ms session). Add
MedianApiLatencyMs per location + global (upper median over per-session
averages, duration convention), base ApiLatencyVsGlobalPct on medians, and
switch web display/sort to the median with the weighted avg in the tooltip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 14:59:42 +02:00
Oliver Kieselbach 3ac647a680 mcp tests: exact hostname match in Entra fetch stub (CodeQL alert #76)
The test-only fetch stub matched the Entra authority via substring
(includes), which CodeQL flags as incomplete URL sanitization. Parse the
URL and compare the hostname exactly instead — also strictly more precise
for what the stub intends (intercept only the Entra host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:06:28 +02:00
Oliver Kieselbach ecf6e8c5a8 clickable TipOfTheDay 2026-08-15 10:59:54 +02:00
Oliver Kieselbach f26721c0f7 web: progress-portal serial-knowledge model — lookup client, serial proof on events/join, reconnect re-proof, visible join denials
- useProgressSearch calls the server-side lookup; the client-side filter
  over the tenant list is gone with the list itself.
- useProgressEvents passes the serial on every events read and refreshes
  the session summary via the lookup (guarded by sessionId so a newer
  re-enrollment of the same serial cannot swap the selected session).
- SignalRContext.joinGroup takes {serialNumber, onDenied}; the serial is
  remembered per group and re-presented on the automatic post-reconnect
  rejoin — without that, a roleless viewer's live updates would die on
  the first network blip. A refused join is now visible (console.warn +
  progress-page notification) instead of a silently frozen page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:42:36 +02:00
Oliver Kieselbach 789926fcad backend: progress-portal serial-knowledge model — server-side lookup, serial proof on events + session-group join
Security finding: GET progress/sessions shipped the tenant's latest 100
sessions to any authenticated (roleless) user with the serial filter
applied only in the browser; events and SignalR session-group joins were
open to any known sessionId.

Model now: knowing the device's serial number is the authorization proof.
- progress/sessions/lookup (AuthenticatedUserWithRole + QueryParam)
  resolves at most ONE session server-side: roleless callers need the
  exact serial/device name, members/GA keep substring search. The
  tenant-wide list route is gone (catalog fail-closes it).
- progress events require ?serial= re-proof on every read; missing
  session and serial mismatch answer the same 404 (no existence oracle).
- Session-group joins by roleless same-tenant callers require the serial
  (SerialKnowledgeProof, shared with the events route); member/GA/
  delegated joins are unchanged. MemberRead on progress routes stays
  deliberately rejected (regression history c4dabeee).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:42:24 +02:00
Oliver Kieselbach 01e76a6ff7 mcp: OAuth-proxy hardening — Entra token timeout, https boot guards, replay note
- /oauth/token: AbortSignal.timeout(15s) on the outbound Entra fetch;
  TimeoutError maps to 504 token_exchange_timeout (502 stays for hard
  failures). Undici defaults previously allowed ~300s per stalled request.
- Boot guards (fail-fast, matching config.ts posture): AUTOPILOT_ENTRA_AUTHORITY
  must be https (sovereign clouds allowed, host not pinned); MCP_PUBLIC_URL pin
  must be https (http tolerated for loopback dev only), trailing slashes
  stripped; production log hint when AUTOPILOT_ENTRA_CLIENT_SECRET is unset
  (public-client vs AADSTS7000218 misconfig).
- State one-time-use: documented as accepted residual risk (a nonce store would
  reintroduce the shared state the stateless multi-replica design removed;
  impact bounded by single-use PKCE-bound Entra codes + callback re-validation).
- Tests: oauth-boot-guards.test.ts (validators) + token-timeout route tests
  with Entra-host-scoped fetch stub. Suite: 559 passed, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:31:45 +02:00
Oliver Kieselbach daa6df6bab rules: evaluate 30 device-phase built-ins at the WhiteGlove seal, 6 also on_event
Every built-in whose evidence exists by the end of pre-provisioning Part 1 now
carries evaluateOn [enrollment_end, whiteglove_sealed] — app failures, low disk,
memory pressure, unsupported build, WU failures, clock skew, ESP device
subcategories, mid-ESP reboots, retry-recovery, cert store, local admins,
critical vulnerabilities, firmware CA2023, Secure Boot, AutoLogon, PPKG scans,
Office install and hybrid/TPM/MDM known-error codes. Six rules additionally
evaluate when their (rare, acute) trigger event arrives:
on_event:app_install_failed → APP-002/004/007 + CORR-003,
on_event:windows_update_failed → DEV-004,
on_event:modern_deployment_error → ENRL-003.

Deliberately enrollment-end only: APP-010/ESP-004/ENRL-001 (require
enrollment_complete/failed), ENRL-002 (session_timeout), DEV-001 (Hello =
user phase), APP-015 (user phase, terminal-precondition design), ID-004
(keeps its own hybrid_login_pending trigger). Minor version bump on all 36
changed rules (seed contract); rule JSONs normalized to 2-space formatting;
semantic equality vs HEAD verified (only evaluateOn/version differ).

Backend 4157 tests green (APP-013 version pin updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:29:45 +02:00
Oliver Kieselbach 98fd173fb8 rules+backend+mcp: rule-verify sweep fixes (dead factor, {{token}} evidence, stats cap, list stringify)
Full verify pass over all 54 built-in rules (validate_rule sweep, 30d/7d
get_rule_stats, test_analyze_rule spot checks). Fixes:

- ANALYZE-APP-007 v1.1.0: declare enrollment_failed as a non-required
  condition — the exists-factor referenced an undeclared signal and could
  never apply.
- ANALYZE-APP-014 v1.1.0: add non-required event_data conditions for
  espCategory/espErrorCode/topCandidatePackage/candidateCount/confidence
  so all explanation {{tokens}} resolve instead of rendering literally.
- ANALYZE-ENRL-002 v1.2.0: capture session_timeout.timeoutHours so
  {{timeoutHours}} interpolates (verified via dry-run: renders "5").
- RuleEngine TryFlatLookup/ResolveDotPath: flatten IEnumerable data
  values to a comma-joined string — List fields (e.g. app_tracking_summary
  .pendingNames) stringified to "System.Collections.Generic.List`1[…]",
  breaking contains-matching and {{pendingNames}} interpolation.
- GetRuleStatsAsync: raise the 500-row cap to 10000 and log when hit.
  The scan walks dates ascending, so the old cap silently dropped the
  NEWEST dates of a 30d window — newly added rules (e.g. ANALYZE-SEC-008)
  were entirely missing from global stats.
- MCP validate_rule lint: accept event_data_array itemField as a {{token}}
  resolver — {{identity}} in ANALYZE-SEC-005/006 was a false positive.

Backend 4157 tests green, MCP 548 green + tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:50:29 +02:00
Oliver Kieselbach 06626a4095 web: evaluateOn in the analyze-rule editor + interim-trigger badge
The evaluateOn feature was API/MCP-only — portal users could not see or set
evaluation triggers. The rule editor gains an Evaluation Triggers section
(enrollment end / WhiteGlove seal checkboxes + on_event type list) with
client-side grammar + hard-block validation sourced from the baked guardrails
mirror (combine.js now emits BLOCKED_INTERIM_TRIGGER_EVENT_TYPES for the web);
rule cards show an 'Interim: …' badge for interim-enabled rules (incl. the
built-in ANALYZE-ID-004). Create/edit payloads round-trip evaluateOn; the
template-copy path already deep-clones it server-side.

Suites: web 762 + tsc + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:34:56 +02:00
Oliver Kieselbach 22a8c7a51d rules: hard-block high-frequency event types as evaluateOn on_event triggers
A warning does not stop an end user — telemetry-cadence types (performance_snapshot,
agent_metrics_snapshot, download_progress, network_state_change/connectivity_check,
log_entry, agent_trace, stall_probe_check) would turn every ingest batch into an
analyze run. Now enforced in three layers:

- AnalyzeRuleTriggers.BlockedOnEventTypes (code-only enforcement): CRUD write is
  rejected with an explicit message, and OnEventTypes() drops blocked types at
  runtime so even a persisted row (pre-validation data, direct table write) is
  inert for the trigger registry and the engine alike.
- rules/guardrails.json gains blockedInterimTriggerEventTypes as the display/
  pre-flight mirror (baked into the MCP RULE_GUARDRAILS via combine.js); a
  parity test pins mirror and code set against drift — same pattern as the
  gather hard blocks.
- MCP validate_rule: the high-frequency finding is now an ERROR sourced from the
  baked mirror, not a hardcoded warning.

Suites: Backend 4155, MCP 548.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:07:39 +02:00
Oliver Kieselbach 72136f0273 backend+mcp+web: evaluateOn interim triggers for analyze rules
Implements docs/rules/analyze-rule-triggers.md (spec approved today):

- AnalyzeRule.EvaluateOn (absent = [enrollment_end], today's behavior bit-for-bit)
  with the shared AnalyzeRuleTriggers grammar/matching helpers; grammar enforced
  in tenant rule CRUD and the analyze-rule JSON schema.
- RuleEngine: AnalyzeRunContext (Terminal/Interim/Reanalyze), interim rule filter,
  KO suppression on interim runs, and RuleResult update-semantics replacing the
  permanent dedupe freeze — interim rows refresh, the terminal pass finalizes or
  resolves them (ResolvedAt, kept for audit), Reanalyze preserves lifecycle markers.
- Notification dedupe moves onto RuleResult.NotifiedAt (one send per session+rule,
  stamped after a successful send; survives the manual reanalyze rebuild).
- Handler: whiteglove_sealed / interim_trigger reasons — notify + SignalR yes,
  IssuesDetected + rule stats terminal-only (Rule Regression Radar baselines clean).
- Ingest: InterimTriggerRegistry (per-tenant 5-min TTL cache, fail-soft) gates the
  interim_trigger enqueue (batch eventType intersection) and the whiteglove_sealed
  enqueue on the first genuine WhiteGlove seal.
- Storage: EvaluateOnJson on AnalyzeRules; FirstDetectedAt/LastEvaluatedAt/
  IsInterim/ResolvedAt/NotifiedAt on RuleResults (nullable — legacy rows read as
  final).
- MCP: three authoring lints (terminal-precondition trap, unknown/high-frequency
  on_event types, KO-on-interim), authoring guide + baked schema updated;
  get_session_summary marks interim and excludes resolved findings.
- Web: preliminary badge on interim findings, resolved findings behind a toggle,
  issue counts exclude resolved (analysis section + diagnosis flow).
- ANALYZE-ID-004 v1.1.0: first interim-enabled rule (on_event:hybrid_login_pending),
  threshold raised to 75 so only repeated overdue-login signals fire mid-run.

Suites: Backend 4152, MCP 548, Web 762 + tsc + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:53:02 +02:00
Oliver Kieselbach 9c96881242 docs: spec for analyze-rule evaluateOn triggers (interim evaluation)
OKF concept doc (status SPEC): opt-in interim analyze runs via evaluateOn
(enrollment_end default, whiteglove_sealed, on_event:<type>), RuleResults
update-semantics replacing the permanent dedupe freeze, KO/stat suppression
on interim runs, notification marker decoupled from row existence, and the
validate_rule lints that keep interim rules monotonic. Closes the design for
both the sits-d WhiteGlove notification gap and the esa never-terminal
stuck-session gap with one mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:03:25 +02:00
Oliver Kieselbach 6c6014940e rules: hybrid user-affinity + never-settled-app detection, dsregcmd on_event snapshot, evidence interpolation fixes
ANALYZE-ID-004 (hybrid sign-in never establishes Entra user affinity),
ANALYZE-APP-015 (required app pending forever, ESP completion deferred),
ANALYZE-ENRL-002 v1.1 (desktop-reached + apps-done evidence), GATHER-DEVICE-006
(dsregcmd /status triggered by hybrid_login_pending). Engine: list evidence
values now comma-joined for UI/{{token}} interpolation; timeoutHours added to
the evidence whitelist so ANALYZE-ENRL-002's explanation interpolates.

Motivated by esa-logistics sessions 3ac8cb0e/95f71149 (user-phase stuck,
GlobalProtect never settling, placeholder identity persisting across reboots).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:39:16 +02:00
Oliver Kieselbach c60c0bbede settings: admit Operators and the GlobalReader read-only (user decision)
The /settings layout now admits any own-tenant role or platform scope via
hasOwnTenantOrPlatformRole — the same set the sidebar's Configuration
visibility uses, closing the "sidebar shows it, guard bounces it" mismatch
the nav matrix test had ratcheted. Read-only stays enforced by the data
layer: MemberRead serves non-admins redacted config, canEditConfig is
false, and every write path (PUT + field PATCH) is TenantAdminOrGA.
This also realigns the code with the customer docs, which already
documented Operators/Viewers as read-only settings viewers.

The guard-consistency ratchet is now empty and may only stay empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 00:04:13 +02:00
Oliver Kieselbach c4280e823c fragility audit P5.x + P7: drift guards across SignalR names, RowKeys, config sections, scoped routing
P5.x-A: Constants.SignalRMessages catalog (13 names) + SignalRGroupHelper.HubName;
all backend send sites and web subscriptions typed against the shared-manifest
union (SignalRContext.on/off) — unknown names are tsc errors. Dead eventStream
session-payload handling removed from diagnosis page.

P5.x-B: RowKeyCodec as the single inverted-tick encoder (persisted widths pinned,
UserActivity D20 legacy); DeletionRowDump.Table on tombstone rows with
DeletionTombstoneTables.Resolve replacing the duplicated Contains('_') heuristic
(legacy fallback retained for pre-field manifests).

P7.1: PATCH config/{tenantId}/fields flipped to TenantAdminOrGA (phase 2);
Settings sections save via per-section diff PATCH from sectionFieldMap.ts —
the ~39-field stale round-trip vector is gone; legacy webhook clearing now only
on the notifications save; agent-settings reset no longer discards contact edits.

P7.2/7.3: useGlobalAdminScope is a projection over useAggregatedAdminScope
(resolveConcreteScopeView, test-pinned); scopedApi builders own the tenant/global
endpoint-pair routing for all 11 former hand-rolled call sites; scopedFetch adds
the shared JSON fetch layer.

P7.4: navVisibility extraction + roles x nav-config matrix and nav<->route-guard
consistency test with a ratchet for the two known pre-existing mismatches
(Operator and GlobalReader vs /settings — user decision pending).

P7.5: web build stamp public/version.json (prebuild, GITHUB_SHA), SWA no-store
route, deploy-web verify poll + versions/web.json manifest, Portal Build card.

P7.6: OkAsyncBaselineGuardTests freezes the 44 anonymous-response sites
(shrink-only ratchet).

Verified: dotnet build 0W/0E, Functions.Tests 4123, DecisionCore 519, V2.Core
2206, SummaryDialog 44; tsc 0, ESLint 0, vitest 762/762; mutation proofs for all
new drift nets. Docs: docs/drift-guards.md + versioning.md web row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:17:48 +02:00
Oliver Kieselbach 6dc2506fc5 web: latch server-side session search to once per query
Live verification after the P5/P6 deploy caught a request storm: the dashboard
auto-sweep effect refires whenever the loading flags toggle, and a q= sweep
whose matches were all already loaded changes nothing the effect watches — so
the same /api/search/sessions request looped indefinitely (~6 req/s observed).
The old loadAll() sweep terminated the effect via hasMore=false; searchAll()
deliberately leaves pagination state alone and therefore needs its own latch.

searchAll now runs at most once per trimmed query (latched before the first
await, also on failure); refetch/refetchWith/global-mode-toggle clear the latch
so a new backend scope may search again, and the explicit "Search all sessions"
button bypasses it via force. Ref declared with the sibling refs BEFORE the
mutating callbacks — the React compiler only accepts ref mutations when the
useRef declaration precedes the closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:24:05 +02:00
Oliver Kieselbach 88cd29d3b9 backend: lifecycle completeness nets + session-scope point-read + server-side dashboard search (audit P5/P6)
P5.1 Table lifecycle buckets: TableLifecycleBucketTests classifies every
TableNames constant (offboarding buckets via reflection, per-session set from a
real DeletionManifestBuilder run, reviewed KeptByDesign list). Fixes found by
the audit: UsageMetrics offboarding wipe matched 0 rows (PK=date/RK=tenantId)
-> new SafeWipe Variant D (RowKey-anchored, verify protects the "global" row);
PlatformStats tenant partition + ScriptNameCache never wiped -> tenant-PK bucket.

P5.2 SessionsIndex field manifest: SessionIndexFieldManifest (32 always / 18
conditional / 4 primary-only) pinned bidirectionally against
BuildSessionIndexEntity and MapToSessionSummary; full rebuilds now
TableUpdateMode.Replace (heals stale conditional columns); merge sites warn on
non-manifest keys; force-path now mirrors StalledAt write + clear heals.

P5.3/P6.1 Session scope resolution: ResolveSessionScopeAsync (RequestContext)
replaces the 16-site copy-paste fallback; ResolveSessionTenantIdAsync does a
point-read on the new SessionTenantLookup table (PK=sessionId) with legacy
index-scan fallback + self-heal, replacing the cross-partition scan; the
"Count == 0 -> rescan" heuristics are gone. Write paths gate cross-tenant
resolution on IsGlobalAdmin. RecomputeTriggerGate enforces the decided
read-only semantics API-side: reanalyze/rescan now 403 for Viewer/GlobalReader/
cross-tenant tenant roles. Lookup rows ride the deletion cascade (step 22).

P6.2 Server-side dashboard search: q= on search/sessions (+global) matching
exactly the client-side searchable fields, page-backfill loop (max 10 Azure
pages/request), q bound into the pagination fingerprint; the web dashboard's
searchAll() replaces the loadAll() full-history sweep (dedupe-merge, delegated
callers keep the legacy sweep).

P6.3 Context value memoization: TenantConfigContext + Auth/Notification/
Tenant/Theme providers (cuts the externally-triggered re-render cascade).

Verified: solution build 0W/0E; Functions.Tests 4106 (+37), DecisionCore 519,
V2.Core 2206, SummaryDialog 44; tsc/eslint clean; vitest 722 (+4). Drift
detection of both new manifest tests proven by mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:29:27 +02:00
Oliver Kieselbach f4d99b43f4 chore: remove accidentally committed verification screenshots
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:04:40 +02:00
Oliver Kieselbach 6c2b431aec sessions: viewer-aware empty state for analysis results
The empty state no longer tells read-only viewers to click a button
they do not have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:04:16 +02:00
Oliver Kieselbach 383d0694fe rbac: wire Viewer as a real read-everything/change-nothing tier
Four-tier model: Member (Progress Portal only) < Viewer (sees everything,
changes nothing) < Operator < Admin. Backend policies already treated
Viewer correctly (MemberRead admits it, every write tier excludes it) —
this change makes the tier reachable and safe:

- role assignment now validates against Constants.TenantRoles
  (case-insensitive, canonicalized, 400 on unknown) — previously ANY string
  was persisted; also fixes the case-sensitive last-admin guard
- Viewer is assignable in all four role dropdowns; About Roles describes
  the four tiers
- shared hasTenantReadScope() replaces four hand-rolled predicates
  (AuthGate, dashboard x2, useDashboardSessions); Viewers land on the
  dashboard and the full navbar/sidebar ("adminOrOperator" visibility
  renamed to "tenantMember"); Settings admits Viewer read-only
- "Analyze now" stays Admin/GA/Operator (recompute trigger, not a view)
- deleted dead IsTenantMemberAsync (meant Admin-or-Operator — the inverse
  of RequestContext.IsTenantMemberRole)
- 16 endpoint tests for role validation, 15 tests for the scope helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:51:59 +02:00
Oliver Kieselbach 792b7d3b0e agent: enforce DecisionState.SchemaVersion at recovery (branch a2)
A snapshot written by a different state-schema generation (e.g. the
pre-self-update binary) is no longer trusted: it is quarantined like a
corrupt cache and the state is rebuilt from the authoritative SignalLog,
whose signals carry their own KindSchemaVersion — replaying them through
the current reducer is the defined upgrade path. Unreadable log escalates
to the existing total-loss quarantine. Closes the silent-Newtonsoft-default
window where a reshaped field could cause mid-session amnesia across a
self-update. Two adversarial recovery tests pin both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:51:58 +02:00
Oliver Kieselbach c48f95b88c tests: cross-language parity manifest + agent safety nets (audit P3/P4)
P3 — shared manifest infrastructure: SharedManifestParityTests reflects the
wire-relevant catalogs out of AutopilotMonitor.Shared into a committed JSON
manifest (regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test); a web codegen
lifts it into literal types; sharedManifestChecks.ts enforces
AdminConfiguration field parity at COMPILE time and 12 vitest tests pin the
value catalogs (sources/operators, lanes/verdicts, severities incl. the
missing Trace=-1, phases both directions, terminal statuses, event-type
typo guard). KnownSources/KnownOperators made internal for the export.

P4 — agent safety nets: DecisionState builder/serializer completeness
(4-site sync tripwire), signal-kind fixture coverage with a shrink-only debt
set (found and fixed realmjoin-timeout-v1.json, which never deserialized),
RemoteConfigMerger completeness tripwire, DefaultComponentFactory ordering
invariants as real construction tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:18:18 +02:00
Oliver Kieselbach 4c413337ca annotations: serve writableLanes from the backend write matrix (audit P2)
The GET now computes writableLanes with the SAME function the PUT re-gates
with (IsLaneWritableByCaller, own-tenant binding included) and the card
renders exactly that list — the hand-mirrored TS matrix is deleted. Fixes a
latent UX authz bug (a GA without an own-tenant admin role was offered the
tenantadmin lane and would have hit a 403). Older backends without the field
fail closed to read-only, so web can ship independently; save affordances
appear once the backend is deployed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:18:05 +02:00
Oliver Kieselbach 8277d70915 fix: close live catalog/type drift across web, backend and agent (audit P1)
- adminConfig.ts: drop retired V1 latestAgent*/customSettings ghost fields,
  add the 8 backend fields the interface was missing
- ops alerts: register the Sla category (BreachNotification/ConsecutiveFailures/
  EvaluationCompleted) + OrphanEventsCleaned so operators can route them
- analyze rules: expose app_install_duration in the builder SOURCES;
  fix stale XML docs (event_data_array, count_per_group_gte/in/not_in)
- UploadIntervalSeconds: collapse 5 diverged defaults (10 vs 30) onto
  Constants.DefaultUploadIntervalSeconds = 10 (the served value)
- combine-rules workflow: commit ALL three combine.js outputs and include
  rules/guardrails.json in the path filters
- api.ts apps sessions builders: move trailing optional strings into an
  options object (adjacent optional strings swallowed a forgotten tenantId)
- SummaryDialog: canonical phase display names ("Apps (Device)")
- mcp-server.bicep: fix pre-EU-cutover hostname in deploy docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:17:51 +02:00
Oliver Kieselbach e1b3ec25fd web: wrap analysis-card context notes on mobile; fix NaN critical path
The confidence bar and the two fleet-context notes on an analysis finding
shared a non-wrapping flex row, so on phones they squeezed into
word-per-line columns — the row now wraps, each note taking its own line.

Also visible in the same screenshot: "critical path NaNh NaNm" in the
ESP-blocking apps block. The backend omits null fields (WhenWritingNull),
so espAppsOccupancySeconds arrives as undefined and the strict !== null
guard let it through to formatDuration. Loose != null check now hides the
label when the value is absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 14:33:35 +02:00
Oliver Kieselbach 321ae63c63 insights: app-version duration regression radar + per-version duration stats
versionBreakdown on the app analytics responses now carries measuredInstalls,
medianDurationSeconds and p95DurationSeconds per version (measured population:
succeeded, non-skip, plausible duration, no AppId collision). A new pure
AppVersionRegressionRadar detects "median install duration rose from X to Y min
after version Z": versions ordered by first-seen install time (never string
sort), fires at median lift >=2x AND >=300s absolute with >=10 measured
installs on both sides.

Episodes follow the rule-radar pattern in a fourth tracker keyspace
appversionregression|{app}|{version} (409-dedup, refresh keeps FirstNotifiedAt,
re-arm on drain / median back under 1.5x, 30d retention sweep re-arms): one
tenant bell app_version_duration_regression (Admin audience, appDetailUrl href)
plus one AppVersionDurationRegression ops event per episode, dual-registered in
the web OPS_EVENT_TYPES catalog. Runs in the maintenance timer after the rule
radar over one cross-tenant 35d install-summary read; kill switch
AppVersionRegressionRadarDisabled (fail-open). Apps detail page shows an amber
regression banner and a median-duration-by-version chart; OKF concept doc added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:39:03 +02:00
Oliver Kieselbach 842db1603a insights: fleet context on analysis cards + rule-filtered session deep link
Each fired analyze rule on the session detail page now reads "Fired in N
enrollment(s) in the last 14 days" (summed client-side from the existing
rule-stats trend — enrollments, never devices; RuleStats carries no serial)
with a "View sessions" deep link. The dashboard accepts ?ruleId=, fetches
the rule's hit-session ids once from the new GET /api/metrics/rule-hit-sessions
endpoint (MemberRead + QueryParam scoping, capped 2000 with a truncated flag)
and filters the loaded list client-side behind a clearable chip.

Side fix: the section's stats fetch now follows the session's tenant for
GA/delegated viewers (global route + tenantId) instead of showing the
viewer's own-tenant numbers next to a foreign session. Dashboard links in
fleet/fleet-health now go through the new canonical dashboardUrl() builder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:38:52 +02:00
Oliver Kieselbach c770bbffcf build: keep NuGet audit advisories as warnings in the warning freeze
A CVE published against an already-referenced package must not block an
unrelated hotfix deploy; advisories are handled by bumping the package.
WarningsNotAsErrors NU1901-NU1904 - everything else stays fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:49:27 +02:00
Oliver Kieselbach 3fbdc8ef62 build: freeze .NET builds at zero warnings (warnings-as-errors)
Repo-wide Directory.Build.props: TreatWarningsAsErrors +
MSBuildTreatWarningsAsErrors. Applies to compiler, MSBuild and NuGet
restore warnings including audit advisories (NU1901-NU1904) - a newly
published CVE fails the build until addressed (fail-closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:44:40 +02:00
Oliver Kieselbach 35cea4fe64 build: clear all remaining .NET build warnings to zero
- CmTraceLogParser: annotate TryParseLine (string? line, [NotNullWhen(true)]
  out entry), init string properties (CS8618/CS8625/CS8604)
- Shared: internal NotNullWhen polyfill for netstandard2.0; multi-target
  netstandard2.0;net10.0 so net10 consumers use the BCL attribute and the
  polyfill cannot collide via InternalsVisibleTo (CS0433)
- AgentEndpointMigrationRules.TryNormalizeTarget: now carries [NotNullWhen(true)]
- Functions.csproj: drop DataProtection/Cryptography.Xml security pins made
  redundant by .NET 10 framework-package pruning (NU1510)
- Functions.Tests.csproj: add Microsoft.AspNetCore.App FrameworkReference so
  pruning applies there too and the pins stay unnecessary (NU1903/NU1904)
- Agent tests: nullable test-helper param, Assert.NotNull before dereference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:38:41 +02:00
Oliver Kieselbach ade6c4948f annotations: tenant switcher on the overview page + GA-lane guard on the global list
/annotations now carries the standard cross-tenant scope (GA/Reader: "All
tenants" aggregate + per-tenant drill-down via GET global/session-annotations,
delegated: managed subset), with a tenant column and tenant-qualified session
deep links. The global list endpoint now derives excludeGlobalAdminLane from
HasGlobalScope so delegated-rescue callers never receive the platform-internal
globaladmin lane (endpoint-test-pinned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:27:31 +02:00
Oliver Kieselbach 83da522dd7 ci: freeze the web lint warning cap at zero (batch 5)
The 99-warning legacy backlog from the React 19 / eslint-plugin-react-hooks
v6 upgrade is fully burned down — CI now fails on any new warning. The
compiler rules stay demoted to "warn" so local dev output remains readable;
the eslint config comments now point to the established fix patterns
instead of the retired ratchet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:23:44 +02:00
Oliver Kieselbach c15394df87 web: refactor context/sidebar/theme effect-state sync, last lint sites to zero (batch 3)
- AuthContext: delete the SSR isLoading effect — effects never run on the
  server, the guard was unreachable dead code.
- TenantContext: mirror user.tenantId via adjust-during-render; the value
  is still retained while auth resolves and cleared on settled sign-out.
- Global/Tenant notification contexts: scope-masked empty list derived in
  the provider value instead of effect-clearing; loading flag moved into
  the canonical async wrapper.
- New shared hooks/useMediaQuery (useSyncExternalStore, configurable
  server snapshot) — consumed by GlobalSidebar (isDesktop) and
  McpTerminalDemo (prefers-reduced-motion, finished transcript derived).
- GlobalSidebar: route-mode active section derived from pathname;
  active-group and pathname auto-expansion as adjust-during-render, state
  now holds user toggles only.
- ThemeContext: theme read via useSyncExternalStore backed by
  lib/themeStore (manual preference beats OS preference in the snapshot;
  same-tab writes notify through the store's listener list).
- TipOfTheDay: random tip via useSyncExternalStore with module-memoized
  pick (build-time Math.random would freeze one tip and mismatch on
  hydration); re-rolls per mount via cleanup.
- Navbar: previewMode block removed entirely — nothing in the codebase
  consumes the flag anymore, it only persisted itself.
- activation page: ?demo flag via useSyncExternalStore, tri-state null
  sentinel dropped.

Lint is at zero react-hooks warnings repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:55 +02:00
Oliver Kieselbach 810a6f1f2f web: derive instead of effect-sync across reset/clamp/seed lint sites (batch 2)
Structural fixes for the set-state-in-effect sites that were genuine
state synchronization, using the React-documented patterns:

- adjust-during-render (compare-prev) for reset-on-dep-change: dashboard
  page reset + tenant-filter clear, InstallsTab/MappedSoftwareTab page
  resets, TenantManagementSection URL seeding + page reset,
  OpsAlertRulesSection draft re-seeding, SessionReportsSection deep-link
  auto-open, ReportSessionModal result reset.
- clamp/derive at read: dashboard effectivePage (also removes the
  documented handleNextPage race), backups detail effectiveTableName,
  useBlockDevice scope-masked set, InstallProgress and
  useProgressDerivedData live timers (startedAt-tagged tick state),
  sessions timeBreakdown read guard.
- key-based reset: SessionReportsSection note editor, DeleteConfirmModal
  content remount.
- inverted state: sessions page stores collapsedPhases so new phases are
  expanded by default with no sync effect; hash deep-link expandAll moved
  into its timer callback.
- tagged result state: useScriptDisplayNames stores {key, map} and serves
  the empty map on key mismatch — same staleness guarantee, no
  effect-phase clearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:36 +02:00
Oliver Kieselbach 019ac3959e web: clear the three preserve-manual-memoization warnings (batch 4)
- useProgressDerivedData: hoist the currentDownload computation into a
  module-level pure helper — the in-loop Map mutation and early return
  kept the compiler from memoizing the body inline.
- DeviceDetailsCard: derive bootTime from events inside the uptime memo
  (deps [events]) — the outer const is a live alias into event data that
  the compiler's alias analysis treats as maybe-mutated.
- useSessionEvents: route the queued follow-up re-invocation through a
  ref — a memoized callback that calls itself directly cannot be
  memoized. The now-traceable mount effect gets the standard wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:56:07 +02:00
Oliver Kieselbach 9236aba46e web: canonical inner-async wrappers for all fetch-effect lint sites (batch 1)
Convert every useEffect that directly invokes a state-setting fetch
callback to the canonical inner-async shape (const run = async () => {...};
void run();) — the react-hooks/set-state-in-effect rule traces direct
calls but accepts effect-scoped async functions. Semantic no-op: an async
body runs synchronously until its first await, so execution order is
byte-identical. Leading loading/error/data resets that are part of the
fetch flow move to the top of run in unchanged order.

52 files, ~60 sites. Lint 99 -> 36 warnings; remaining sites are
reset/derive effects and sensitive context/sidebar refactors (next
batches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:55:49 +02:00
Oliver Kieselbach fda52666d6 web: clear set-state-in-effect lint warning on the annotations page
The annotations overview pushed the ESLint warning count to 100, one over
the frozen --max-warnings 99 cap, failing CI since 66d25f57's run. The
react-hooks/set-state-in-effect rule flags any direct effect call into a
state-setting useCallback, even with every setState behind an await.

Restructure instead of suppressing: loading/error begin-state moves from
fetchPage into the interaction sites (filter selects, load-more; mount is
covered by the initial state), and the effect invokes fetchPage through an
inner async wrapper so no setState is synchronously reachable. Lint is
back to exactly 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:19:52 +02:00
Oliver Kieselbach dcec6a1863 web: friendly empty/error states on the annotations overview page
A 404 (backend without the route yet, deploy skew) now renders the
regular empty state instead of an error; genuine load failures show a
calm non-technical retry hint in amber instead of raw statusText in red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:42:29 +02:00
Oliver Kieselbach 041626494e ci: bump actions/cache v4 -> v6 (Node 24 runtime)
Node 20 actions are deprecated on GitHub runners; actions/cache@v4 was
the last remaining action targeting it (MCP server tests job warning).
v6 runs on node24 like the rest of the workflow actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:41:10 +02:00
Oliver Kieselbach fd32a90d57 web: label the GA annotation lane as internal, with a visibility badge
"Autopilot Monitor Team" read like a vendor-to-customer message channel.
The lane only ever renders for platform-scope users, so it now says
"Internal (Autopilot Monitor)" and carries a "never visible to the
tenant" badge on the session card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:39:25 +02:00
Oliver Kieselbach 90b8243894 annotations: move tenant list route under the /api/sessions cert-exclusion prefix
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:35:54 +02:00
358 changed files with 14582 additions and 1994 deletions
+5 -6
View File
@@ -77,12 +77,11 @@ jobs:
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
# Errors fail outright, and so does ANY warning: the codebase is
# warning-clean and must stay that way — fix new findings, never
# raise the cap.
- name: Lint
run: npm run lint -- --max-warnings 99
run: npm run lint -- --max-warnings 0
- name: Tests
run: npm test
@@ -114,7 +113,7 @@ jobs:
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
# is honored by vector-search-provider.ts.
- name: Cache embedding model
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.hf-cache
key: hf-model-Xenova-all-MiniLM-L6-v2
+7 -3
View File
@@ -8,6 +8,7 @@ on:
- 'rules/ime-log-patterns/**'
- 'rules/schema/**'
- 'rules/scripts/**'
- 'rules/guardrails.json'
pull_request:
paths:
- 'rules/gather/**'
@@ -15,6 +16,7 @@ on:
- 'rules/ime-log-patterns/**'
- 'rules/schema/**'
- 'rules/scripts/**'
- 'rules/guardrails.json'
jobs:
validate-and-combine:
@@ -55,9 +57,11 @@ jobs:
- name: Combine rules into dist/
run: node rules/scripts/combine.js
- name: Commit dist/ changes
- name: Commit generated outputs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: regenerate rules/dist from individual files"
file_pattern: "rules/dist/*"
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
# combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
+56
View File
@@ -16,6 +16,10 @@ on:
- 'src/Web/autopilot-monitor-web/**'
- '.github/workflows/deploy-web.yml'
env:
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
@@ -48,6 +52,9 @@ jobs:
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
GITHUB_SHA: ${{ github.sha }}
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
@@ -64,6 +71,55 @@ jobs:
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
# The SWA action reporting success proves the upload, not that the production host
# serves the new build — verify against the live /version.json (Cache-Control:
# no-store via staticwebapp.config.json) before publishing the version manifest.
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
- name: Verify deployed portal build is live
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
$deadline = [DateTime]::UtcNow.AddMinutes(5)
$attempt = 0
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
if ($v.commit -eq $expectedCommit) {
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
exit 0
}
Write-Host "Attempt ${attempt}: serving $($v.commit) -- waiting for $expectedCommit"
} catch {
Write-Host "Attempt ${attempt}: /version.json not answering yet -- $($_.Exception.Message)"
}
Start-Sleep -Seconds 15
}
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
# Cross-component deploy record, written last and only on a verified deploy —
# same contract as versions/backend.json and versions/mcp.json.
- name: Publish version manifest
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
shell: pwsh
run: |
$manifest = [ordered]@{
component = 'web'
commit = '${{ github.sha }}'.Substring(0, 7)
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/web.json"
Write-Host $manifest
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
+4
View File
@@ -154,3 +154,7 @@ docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
# changes each run) and served at /version.json from the static export.
src/Web/autopilot-monitor-web/public/version.json
+12
View File
@@ -0,0 +1,12 @@
<Project>
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
against an already-referenced package must not block an unrelated hotfix deploy;
advisories are handled by bumping the package, not by failing the build. -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
</PropertyGroup>
</Project>
@@ -0,0 +1,98 @@
---
type: Concept
title: App Version Duration Regression Radar
description: How the radar detects app versions whose median install duration regressed vs. the previous version (median lift ≥2x plus an absolute floor over first-seen-ordered versions), how alert episodes live in the appversionregression tracker keyspace, and which surfaces show them.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs
tags:
- backend
- apps
- regression-radar
- insights
timestamp: 2026-08-13T12:00:00+02:00
---
# App Version Duration Regression Radar
Detects "version X made this app's installs much slower" — the concrete, quotable insight
("median install duration rose from 11 to 29 min after version X") — from the per-(session,
app) `AppInstallSummaries` rows. Duration only: failure-rate per version is already visible
in the apps dashboard's `versionBreakdown`, and rate regressions belong to Wilson-interval
statistics, not medians.
# Schema
## Signal & detection
Per (tenant, app), the MEASURED install population is: `Status == "Succeeded"`, terminal
state not Skipped/Postponed, `MetricsMath.HasMeasuredDuration` (1s..6h — 0s means the start
was unobserved, above 6h means back-stamped batches), no `AppIdCollision`, non-empty
`AppVersion`. Versions are ordered by FIRST-SEEN install time (min `StartedAt`), never by
string comparison ("9.1" vs "2024.10" would lie). The comparison pair is always the newest
version (the one with the most recent measured install) vs. the version with the latest
first-seen strictly before it.
MEDIANS, not means (nearest-rank, shared with the apps dashboard via
`AppsAnalyticsHelper.Percentile`) — one back-stamped straggler must not fire a fleet alert.
A regression fires only when ALL hold over the trailing 35-day horizon:
1. both versions have ≥ 10 measured installs;
2. current median ≥ 2× the previous version's median;
3. absolute increase ≥ 300 s (a 20s→45s app is noise, not an incident).
## Suppression & limits
* Parallel ring rollouts can make "previous" a concurrently-deployed version — accepted in
v1; the episode key (app, current version) still caps it at one bell per version.
* Platform kill switch: app setting `AppVersionRegressionRadarDisabled=true` skips the
radar (fail-open — it only notifies, never mutates data).
* One cross-tenant install-summary read per pass (the same query the global apps dashboard
runs), grouped by tenant; per-tenant failures are non-fatal.
## Alert episodes (tracker keyspace)
An episode lives as `appversionregression|{app-lower}|{version-lower}` (table-key-sanitized;
raw AppName/CurrentVersion live in columns, the mapper never parses the RowKey) in the
notification-tracker table, next to the `ruleregression|` keyspace: the row IS the dedup
(bell + ops event fire exactly once per episode) and the `versionRegressions[]` payload —
both medians, both sample counts, lift. Numbers refresh on every radar pass while the
episode stays active; `FirstNotifiedAt` never moves. The episode closes (dedup re-arms) when
the alerted version drains under 10 measured installs in the horizon or its recomputed
median falls back under 1.5× the previous version's median; the tracker's 30-day retention
sweep also re-arms, so a month-old still-burning regression rings once more by design.
## Notification chain (v1: bell + ops event)
* Tenant bell `app_version_duration_regression` (audience: Admin), href
`/apps/detail?name={app}&days=35` (canonical `appDetailUrl` shape). Message carries the
full numbers so the admin can verify without a portal round-trip.
* Ops event `AppVersionDurationRegression` (Tenant category, Warning) — dual-registered in
the web `OPS_EVENT_TYPES` catalog so operators can wire Telegram/Teams/Slack alert rules.
## Surfaces
* `apps/{appName}/analytics` (+ global twin with `?tenantId=`) responds with
`versionRegressions[]` (empty for the cross-tenant aggregate — episodes are per-tenant
rows), and `versionBreakdown[]` rows now carry `measuredInstalls` /
`medianDurationSeconds` / `p95DurationSeconds`.
* Apps detail page: amber "↑ Duration regression" banner above the version cards and a
"Median Install Duration by Version" bar chart.
# Examples
A tenant ships Contoso VPN 2.4.0 after 2.3.9. 2.3.9 has 40 measured installs, median 660s;
2.4.0 accumulates 12 measured installs with median 1740s. Lift 2.6×, absolute +1080s →
episode `appversionregression|contoso vpn|2.4.0` opens, one bell + one ops event fire.
Subsequent passes refresh the numbers only. When 2.4.1 replaces 2.4.0 and 2.4.0 drains
below 10 measured installs in the horizon, the episode deletes and the dedup re-arms.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs` — pure core
(gates, first-seen ordering, re-arm).
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs`
— orchestration, kill switch, bell/ops-event emission, wording contract.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs`
— episode keyspace, entity round-trip.
* `src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs` — persisted
alert payload.
* [Rule Regression Radar](rule-regression-radar.md) — the sibling pattern this radar mirrors.
@@ -0,0 +1,62 @@
---
type: concept
title: Lifecycle Manifests & Session Scope Resolution
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
resource: backend
tags: [lifecycle, offboarding, sessionsindex, manifest, scope-resolution, search, performance]
timestamp: 2026-08-13
---
# Schema
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
## 1. Table lifecycle buckets
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
| Bucket | Mechanism | Source of truth |
|---|---|---|
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
## 2. SessionsIndex field manifest
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
## 3. Session scope resolution (one implementation)
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
- Non-global callers always keep the middleware-validated `TargetTenantId`.
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
- `requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
## 4. Server-side dashboard search
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
# Examples
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
# Citations
- `src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs`
- `src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs` (`ResolveSessionScopeAsync`)
- `src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs` (Variant D)
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` (`MatchesFreeText`, backfill loop)
+16 -5
View File
@@ -42,7 +42,7 @@ Routes (all in `EndpointAccessPolicyCatalog`):
| --- | --- | --- |
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream |
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
@@ -55,7 +55,10 @@ GA writes exactly the `globaladmin` lane (the platform labeling flow).
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
admins). Tenant lanes are readable by all members of that tenant.
admins). Both list endpoints enforce the same rule server-side via the repository's
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
members of that tenant.
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
@@ -82,12 +85,20 @@ in the tenant-visible audit log — same convention as GA session-report submiss
count + verdict-pill summary header.
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
dilute both): tenant-scoped list backed by `GET session-annotations`
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The tenant is always the
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
middleware-validated `TargetTenantId`, and for callers without global scope the repository
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
deep-link to `sessions?id=…#section-annotations`.
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
`tenantId`.
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
+106
View File
@@ -0,0 +1,106 @@
---
type: Concept
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
tags:
- contracts
- guard-tests
- signalr
- web
timestamp: 2026-08-13T00:00:00+02:00
---
# Drift Guards
Follow-up to the 2026-08 fragility audit (see [Lifecycle Manifests & Session Scope
Resolution](backend/lifecycle-manifests-and-session-scope.md) for the earlier rounds).
Each section below replaces a hand-synchronized mirror with an enforced contract: a
change on one side now fails a build, a type check, or a test on the other side.
# Schema
## SignalR message names — one catalog, typed on both ends
`Constants.SignalRMessages` (Shared) is the complete catalog of SignalR target names.
Backend senders (output bindings and the imperative `SignalRNotificationService`)
reference the constants; the hub name is `SignalRGroupHelper.HubName`. The catalog is
exported into `shared-manifests.json` (`signalRMessages` section) by
`SharedManifestParityTests`, and the web's `SignalRContext.on/off` type their event
name against the generated union (`lib/signalrMessages.ts`) — subscribing to a name
the backend never sends is a tsc error, not a silent no-op. `newevents` is the one
legacy lowercase name; it is a persisted wire name, do not "fix" the casing.
## Inverted-tick RowKeys — one codec
`RowKeyCodec` (Functions/Helpers) is the single encoder/decoder for
`rev(t) = MaxTicks - t.Ticks` RowKeys. Every writer routes through it; the persisted
per-table shapes (D19 standard, AuditLogs `!`-prefix, UserActivity's legacy D20 width,
ConfigurationBackups' 28-char truncation) are pinned by `RowKeyCodecTests` and must
never change — a width/prefix change reorders live tables against their existing rows.
`BusinessTimestamp` keeps the OData clause builders and delegates decode to the codec.
## Tombstone rows carry their table
`DeletionRowDump.Table` names the source table on FINAL (Tombstone) rows — the one
step that mixes two tables while `DeletionStep.Table` is null.
`DeletionTombstoneTables.Resolve` is the only consumer-side resolution (delete +
restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic survives
solely as the fallback for manifests written before the field existed and must outlive
one full manifest-retention cycle.
## Anonymous OkAsync responses — frozen, shrink-only
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
sites as a per-file baseline. A new anonymous response (new file or count above
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
list stays truthful and only ever shrinks.
## Tenant settings — per-section PATCH
The web's Settings sections no longer PUT the full ~92-field configuration. Each
section's exact write surface lives in `app/settings/sectionFieldMap.ts` (owned fields
plus documented `alsoWrites` write-throughs); `saveConfiguration` diffs those fields
against the loaded config and PATCHes only the changes through
`PATCH config/{tenantId}/fields` — the transactional endpoint (CAS + fail-closed
backup + exactly-these-fields verify + auto-rollback) that previously served MCP only.
The policy line is TenantAdminOrGA; a tenant admin runs on the stricter
`TenantConfigCallerTier.TenantAdmin` whose deny-list turns GA-only fields into
explicit 400s. Deploy order: backend before web. `sectionFieldMap.test.ts` pins
field↔section ownership (each field exactly one owner, all fields on the model, none
server-denied); backups/revert stay GlobalAdminOnly.
## Scoped endpoint routing — one decision object
`lib/scopedApi.ts` owns the tenant/global endpoint-pair choice: pages pass their scope
hook object (`routeGlobal`, `selectedTenantId`, `effectiveTenantId`) instead of
hand-rolling `routeGlobal ? global(..., sel || undefined) : tenant(tid, ...)` with the
tenant parameter at a different position per pair. `useGlobalAdminScope` is now a thin
projection over `useAggregatedAdminScope` (one hook, aggregated as a mode) via the
pure `resolveConcreteScopeView`. `lib/scopedFetch.ts` adds the shared JSON+ok-check
fetch layer. `lib/navVisibility.ts` extracts the sidebar's roles × nav-config × guard
logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it"
mismatches (plain Operator and GlobalReader vs. `/settings` — pending a user decision).
# Examples
Renaming a SignalR message end to end: change the constant in
`Constants.SignalRMessages` → `AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter
SharedManifestParityTests` → `node scripts/generate-shared-manifest-types.js` → tsc
now flags every stale web subscription literal.
Adding a field to a Settings section: add it to the section in `sectionFieldMap.ts`
(tsc validates the name against the manifest), wire the form state into the
`updatedConfig` construction in `TenantConfigContext`, and extend the expected-fields
list in `sectionFieldMap.test.ts`.
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
+4
View File
@@ -14,6 +14,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
# Agent
@@ -31,6 +32,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
* [Analyze Rule Evaluation Triggers](rules/analyze-rule-triggers.md) - Opt-in `evaluateOn` interim triggers (`whiteglove_sealed`, `on_event:<type>`) so rules can fire before a session is terminal, with RuleResults update-semantics (interim → finalize/resolve, NotifiedAt notification dedupe) replacing the permanent dedupe freeze, KO/stat suppression on interim runs, the ingest-side cached trigger registry, and the authoring lints that keep interim rules monotonic.
# Backend
@@ -39,10 +41,12 @@ https://docs.autopilotmonitor.com (separate repository).
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
# MCP Server
+28
View File
@@ -1,5 +1,33 @@
# Log
## 2026-08-17
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
## 2026-08-14 (later)
* **Update**: `rules/analyze-rule-triggers.md` — status SPEC → IMPLEMENTED. Shipped: `evaluateOn` on the analyze-rule schema (grammar-validated server-side and in validate_rule), `AnalyzeRunContext`-driven engine runs (Terminal/Interim/Reanalyze) with the RuleResult lifecycle columns (FirstDetectedAt/LastEvaluatedAt/IsInterim/ResolvedAt/NotifiedAt), interim rule filtering + KO/stat suppression, resolve/finalize update-semantics, the NotifiedAt one-send-per-(session,rule) notification dedupe (preserved across the manual reanalyze rebuild), the ingest-side `InterimTriggerRegistry` (5-min TTL, fail-soft) gating `interim_trigger` / `whiteglove_sealed` enqueues, the three MCP authoring lints, web preliminary/resolved rendering, and ANALYZE-ID-004 as the first interim-enabled rule.
## 2026-08-14
* **Creation**: Added `rules/analyze-rule-triggers.md` — SPEC for opt-in `evaluateOn` interim evaluation of analyze rules: `enrollment_end` default preserves today's behavior, `whiteglove_sealed` + `on_event:<type>` close the WhiteGlove notification gap and the never-terminal stuck-session gap; RuleResults update-semantics (FirstDetectedAt/IsInterim/ResolvedAt/NotifiedAt) replace the permanent dedupe freeze; interim runs suppress KO + stats, notifications fire on first detection; three new validate_rule lints (terminal-precondition trap, unknown trigger type, KO-on-interim).
## 2026-08-13 (late evening)
* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet.
* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published.
## 2026-08-13 (evening)
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
## 2026-08-13
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
## 2026-08-12
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
+4 -1
View File
@@ -25,7 +25,10 @@ Three layers answer "are the MCP tools doing their job, and are tools missing?":
Logs) — the layer that carries what the other two structurally cannot:
- `tool_call`: duration, `isError` **including soft errors** (handlers never
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
records those calls as successes), `resultChars` + `overCap` (result
records those calls as successes), `errorMessage` (capped; the thrown
message or the first lines of the `toolError` text, which name the error
class — HTTP status / timeout / auth / not found; absent on success),
`resultChars` + `overCap` (result
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
it; a frequently-overCap tool needs tighter defaults or projections), a
size-bounded args summary, and the caller scope (ga/delegated/tenant).
+118
View File
@@ -0,0 +1,118 @@
---
type: Concept
title: Analyze Rule Evaluation Triggers (evaluateOn)
description: "Opt-in interim evaluation of analyze rules before a session is terminal — enrollment_end stays the default, whiteglove_sealed and on_event:<type> triggers close the WhiteGlove notification gap and the never-terminal stuck-session gap with one mechanism, backed by update-semantics on RuleResults instead of the permanent dedupe freeze."
resource: backend/rule-engine
tags: [rules, analyze, triggers, notifications, whiteglove]
timestamp: 2026-08-14
---
# Status
**IMPLEMENTED 2026-08-14** (same day the spec was approved). The open product decisions below were confirmed as "no" for v1. Key implementation anchors: `AnalyzeRuleTriggers` (Shared — grammar + matching helpers, shared by engine, ingest registry and CRUD validation), `AnalyzeRunContext` / `AnalysisOutcome.ResolvedResults` (RuleEngine), `InterimTriggerRegistry` (5-min-TTL per-tenant cache for the ingest hot path, fail-soft), `AnalyzeOnEnrollmentEndHandler` interim branch, `EvaluateOnJson` + five RuleResult lifecycle columns in `TableStorageService.Rules`. ANALYZE-ID-004 ships as the first interim-enabled rule (`on_event:hybrid_login_pending`, repetition-gated at threshold 75).
# Problem
Analyze rules run at exactly three points today, all of them terminal or near-terminal:
1. `enrollment_complete` / `enrollment_failed` observed at ingest (`EventIngestProcessor.cs`, classification → analyze envelope).
2. The maintenance sweep terminalizing a silent session (Failed / Incomplete + synthetic `session_timeout`, `MaintenanceService.cs`).
3. The vulnerability-correlation rerun (`vulnerability_correlated` reason — incremental, no stats).
Two incident classes fall through this model:
- **WhiteGlove gap (sits-d, 2026-08-11):** `whiteglove_complete` seals a session to Pending but enqueues no analyze run. A rule that should alert at the end of pre-provisioning (technician still at the bench) never fires, and when Part 2 eventually terminalizes days later, the permanent per-(session, rule) dedupe means a finding that sneaked in earlier (e.g. via the vuln-rerun backdoor) is frozen and never notifies.
- **Never-terminal stuck sessions (esa-logistics, 2026-08-13/14):** a session looping in Account Setup (user signs in daily, hybrid affinity never establishes) resets the 5h inactivity clock on every logon. It is never terminalized, therefore never analyzed — the exact sessions where a diagnosis would help most (ANALYZE-ID-004, ANALYZE-APP-015) produce no finding while the customer watches the device loop.
Both gaps share one root cause: *evaluation time is hard-wired to session end*. Gather rules already solved the analogous problem on-device with declarative triggers (`startup` / `phase_change` / `phase_exit` / `interval` / `on_event`); analyze rules need the server-side equivalent.
# Design Overview
Add an optional `evaluateOn` array to the analyze-rule schema. Absent field ⇒ `["enrollment_end"]` ⇒ **today's behavior, bit-for-bit, for every existing rule**.
```
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"]
```
Trigger grammar (v1):
| Trigger | Fires when | Notes |
|---|---|---|
| `enrollment_end` | terminal analyze run (complete / failed / sweep-terminalized) | default; also the **finalization pass** for interim results (below) |
| `whiteglove_sealed` | ingest classifies the first genuine `whiteglove_complete` (session → Pending, `isPreProvisioned`) | duplicate seals do not re-trigger |
| `on_event:<eventType>` | an ingest batch contains ≥1 event of `<eventType>` | `<eventType>` validated against the event-types catalog; phase boundaries are covered by `on_event:phase_transition` / `on_event:esp_phase_changed` — no separate phase grammar needed, since every run evaluates the full stream anyway |
Explicitly **not** in v1: interval/timer triggers (no cheap host for them; the sweep remains the only clock), and `on_event` filters on data fields (the rule's own conditions do that — the trigger only decides *when* to look).
# Pipeline Changes
## Ingest (producer side)
- `EventIngestProcessor` already classifies each stored batch. After classification, intersect the batch's event types with the tenant's **trigger registry** (the set of `on_event` types across the tenant's active rules — derivable from `AnalyzeRuleService`'s merged rule load, cached alongside it). Non-empty intersection ⇒ enqueue one envelope `Reason=interim_trigger`, carrying the matched trigger event types. Reuse the existing 30s visibility delay (terminal-flush ordering argument in `AzureQueueAnalyzeOnEnrollmentEndProducer` applies to interim batches too).
- The `whiteglove_complete` seal path (`EventIngestProcessor.Classification.cs`) enqueues `Reason=whiteglove_sealed` — only on the first seal, only for `isPreProvisioned`.
- Terminal enqueue (`CompletionEvent` / `FailureEvent`) is unchanged.
## Handler / engine (consumer side)
`AnalyzeOnEnrollmentEndHandler` branches by reason today (primary vs. `vulnerability_correlated`); interim reasons become a third branch. The engine call gets a run context instead of the bare `reanalyze` flag:
| Aspect | Terminal run (`enrollment_end`) | Interim run (`whiteglove_sealed`, `interim_trigger`) |
|---|---|---|
| Rule set | ALL active rules (interim-triggered ones get their finalization pass here) | only rules whose `evaluateOn` matches the reason / one of the batch's trigger types |
| KO (`MarkSessionAsFailed`) | applies (unchanged) | **suppressed** — a Pending/InProgress session must never be flipped Failed by an interim pass (`TryMarkSessionFailedFromRuleAsync` is skipped; the terminal run re-fires the rule and applies KO then) |
| Platform stat `IssuesDetected` | counted | skipped (like vuln rerun) |
| Per-rule fire stats | counted | skipped (like vuln rerun) — keeps the terminal-only stats convention and the Rule Regression Radar baselines clean |
| Channel notifications | new findings notify (unchanged) | new findings notify — **this is the point** of both gaps |
| SignalR results-available | yes | yes (live update of an open session page) |
## Result lifecycle: update semantics replace the dedupe freeze
Today the engine skips any rule that already has a stored `RuleResult` row for the session (`RuleEngine.AnalyzeSessionAsync`, existing-results lookup) — findings are frozen at first write, which is precisely the WG-gap failure mode ("dedupe permanent").
New model — `RuleResult` gains four fields (`FirstDetectedAt`, `LastEvaluatedAt`, `IsInterim`, `ResolvedAt`, plus `NotifiedAt` for the notification marker):
- **Interim run:** rules in scope are (re-)evaluated even when a row exists. Fired ⇒ upsert (evidence/confidence refreshed, `FirstDetectedAt` preserved, `IsInterim=true`). Not fired + existing interim row ⇒ `ResolvedAt` set (kept for audit; excluded from issue counts and default UI).
- **Terminal run:** every rule that has an interim row is re-evaluated regardless of dedupe (finalization). Fired ⇒ row finalized (`IsInterim=false`, evidence refreshed — fixes the stale-template-field freeze, e.g. the ESP-004 `{{appName}}` case). Not fired (typically a `not_exists` precondition on `enrollment_complete` now failing because the session healed) ⇒ `ResolvedAt` set. Rules without interim rows keep today's skip-if-exists dedupe.
- **Notification dedupe** moves off "row exists" onto `NotifiedAt` on the row: notify once per (session, rule) at first fire — interim or terminal, whichever comes first; refreshes and finalization do not re-notify. The manual `reanalyze=true` path must **preserve** `NotifiedAt` across its rebuild (today it deletes rows; delete-and-recreate would silently re-arm notifications) and keeps its designed never-notify behavior.
- Legacy rows (null new fields) read as final/terminal — no migration.
# Authoring Guardrails (validate_rule lints)
Interim evaluation changes what a rule's conditions mean mid-flight. Three new lints in the MCP `validate_rule` pre-flight (and the baked authoring guide):
1. **Terminal-precondition trap (warning):** `evaluateOn` contains an interim trigger AND a precondition is `not_exists` on `enrollment_complete` / `enrollment_failed` / `session_timeout`. Mid-run these pass trivially — the rule needs conditions that are *monotonic* (once true on the growing event stream, stays true) or repetition-gated. Worked example: ANALYZE-APP-015 must NOT get an interim trigger as-is — even successful sessions transiently show its "completion signal deferred" + `pending>=1` pattern (the terminal precondition is its only gate). ANALYZE-ID-004 is interim-safe by construction (threshold 65 requires repeated `hybrid_login_pending`).
2. **Unknown trigger event type (error):** `on_event:<type>` not in the event-types catalog.
3. **KO-on-interim (warning):** `markSessionAsFailedDefault: true` on a rule whose only triggers are interim — the KO would never apply until terminal; say so.
# Cost & Safety
- Interim runs are bounded by trigger frequency, and the chosen v1 triggers are rare by construction (`hybrid_login_pending` ≤1 per boot, `whiteglove_sealed` once per session). High-frequency telemetry event types (performance_snapshot, agent_metrics_snapshot, download_progress, network churn, log_entry, agent_trace, stall_probe_check) are **hard-blocked** as `on_event` triggers, not merely advised against: the CRUD write is rejected (`AnalyzeRuleTriggers.BlockedOnEventTypes`, code-only enforcement), the runtime matcher drops them from any row that slipped in (defense in depth — the trigger registry and the engine both go through `OnEventTypes`), and the MCP lint reports them as an error. The JSON mirror lives in `rules/guardrails.json` (`blockedInterimTriggerEventTypes`) with a parity test pinning it to the C# set — the same pattern the gather hard blocks use. If a legitimate rare-but-bursty type ever hurts, a per-(session, reason) throttle marker is the v2 lever.
- Each run loads the full event stream once (existing engine behavior, no change); rule filtering makes interim passes strictly cheaper than terminal ones.
- Queue semantics (retry via visibility timeout, poison after MaxDequeueCount, idempotent partial-success re-runs) are inherited unchanged; upserts keep idempotency.
# Surfaces
- **Web session page:** interim findings render live (existing SignalR results-available notification) with a "preliminary" badge on `IsInterim`; `ResolvedAt` rows hidden by default behind a toggle.
- **MCP:** `get_session(includeAnalysis)` / `get_session_summary` expose `isInterim` / `resolvedAt`; `list_session_annotations`' fired-rule snapshot semantics unchanged (labels bind to whatever was fired at annotation time).
- **Success-rate / platform stats:** terminal-only convention untouched (interim runs record no stats).
# Application to the Motivating Cases
- WG battery rule (sits-d): `evaluateOn: ["enrollment_end", "whiteglove_sealed"]` — technician gets the channel alert at the bench; Part-2 terminal pass finalizes or resolves the finding.
- Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1).
# Product Decisions (settled 2026-08-14)
1. Notify-on-resolve (a "finding healed" message)? **No** (v1) — resolution is visible in UI/MCP.
2. Re-notify on severity/confidence escalation of an existing finding? **No** (v1) — one notification per finding.
3. `whiteglove_sealed` runs allowing KO rules to flip Pending → Failed? **No** — the Pending state is the WhiteGlove contract; suppression is unconditional for interim.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` — terminal-only enqueue (classification → analyze envelope).
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs` — reason branching, notify path, stats side-effects.
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs` — 30s visibility delay rationale.
- `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs` — existing-results dedupe, `TryMarkSessionFailedFromRuleAsync` KO path.
- `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — sweep terminalization + analyze enqueue; inactivity-clock reset explains the never-terminal gap.
- `rules/gather-rule-phase-scoping.md` (this bundle) — the on-device trigger precedent this design mirrors.
- `rules/rule-authoring-surface.md` (this bundle) — where the new lints land (validate_rule + baked guide).
+16 -6
View File
@@ -1,7 +1,7 @@
---
type: Concept
title: Version Contract — Agent, Backend, MCP
description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
title: Version Contract — Agent, Backend, MCP, Web
description: One version shape for all shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
resource: /.github/scripts/Request-BuildNumber.ps1
tags:
- build
@@ -13,9 +13,12 @@ timestamp: 2026-07-22T00:00:00+02:00
# Version Contract
Three components ship from this repository — the agent, the Functions backend and the
MCP server. They share one version shape, one way of minting build numbers, and one
manifest format, so that a version string means the same thing whoever emits it.
Four components ship from this repository — the agent, the Functions backend, the
MCP server and the web portal. They share one version shape, one way of minting build
numbers, and one manifest format, so that a version string means the same thing
whoever emits it. The web portal is the commit-only exception: a static export has no
runtime that could report a `major.minor.build`, so it carries `commit` + `buildUtc`
alone (no counter blob).
```
version = <major>.<minor>.<build> major.minor curated by hand, build minted per build
@@ -28,6 +31,7 @@ buildUtc = ISO-8601 build moment
| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
| Web portal | — (commit-only) | — | `GET /version.json` on the portal host |
# Schema
@@ -77,7 +81,12 @@ exception: `build.ps1` produces uploadable artifacts and reserves a real number
```
The MCP manifest adds `docsCommit`, because for that component "which build" and "which
documentation" are separate questions.
documentation" are separate questions. `versions/web.json` carries `component`, `commit`,
`deployedUtc` and `runId` (no `version`/`buildUtc` — the served `/version.json` holds the
build moment). The portal's own stamp is generated into `public/version.json` by
`scripts/generate-version.js` (prebuild) from `GITHUB_SHA`, served with
`Cache-Control: no-store`, and `deploy-web.yml` polls it until the deployed commit
matches the pushed SHA before publishing the manifest.
Each is written **last and only after the deploy workflow has polled the component's
health endpoint until it reports that exact version and commit**. A green deploy step
@@ -104,5 +113,6 @@ remain, being the customer-facing integrity contract.
- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
- `.github/workflows/deploy-web.yml`, `src/Web/autopilot-monitor-web/scripts/generate-version.js` — web stamp, verify, publish
- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
+1 -1
View File
@@ -25,7 +25,7 @@
// az deployment group create \
// --resource-group <rg-name> \
// --template-file infra/mcp-server.bicep \
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
// --parameters apiUrl=https://autopilotmonitor-api-eu.azurewebsites.net \
// entraClientSecret=<secret-value>
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.azurecontainerapps.io).
//
+10 -2
View File
@@ -5,8 +5,12 @@
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
"severity": "warning",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 60,
"conditions": [
{
@@ -34,5 +38,9 @@
]
}
],
"tags": ["apps", "detection", "win32"]
"tags": [
"apps",
"detection",
"win32"
]
}
+11 -2
View File
@@ -5,8 +5,13 @@
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
"severity": "high",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -36,5 +41,9 @@
]
}
],
"tags": ["apps", "msi", "error-code"]
"tags": [
"apps",
"msi",
"error-code"
]
}
+9 -1
View File
@@ -6,6 +6,10 @@
"severity": "high",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -29,5 +33,9 @@
]
}
],
"tags": ["apps", "dependency"]
"tags": [
"apps",
"dependency"
],
"version": "1.1.0"
}
+21 -4
View File
@@ -5,8 +5,13 @@
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
"severity": "critical",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"baseConfidence": 75,
"conditions": [
{
@@ -36,8 +41,16 @@
}
],
"confidenceFactors": [
{ "signal": "app_failed", "condition": "exists", "weight": 15 },
{ "signal": "enrollment_failed", "condition": "exists", "weight": 10 }
{
"signal": "app_failed",
"condition": "exists",
"weight": 15
},
{
"signal": "enrollment_failed",
"condition": "exists",
"weight": 10
}
],
"explanation": "Performance monitoring shows disk free space dropped below 5 GB during enrollment. This can cause app installation failures, especially for large Win32 apps.\n\nIf app installations failed in the same session while free space was low, treat storage exhaustion as the likely contributing cause — the confidence score of this finding is raised accordingly. Verify the individual app failures for their concrete error codes before concluding storage was the only cause.",
"remediation": [
@@ -58,5 +71,9 @@
]
}
],
"tags": ["apps", "disk-space", "critical"]
"tags": [
"apps",
"disk-space",
"critical"
]
}
+10 -1
View File
@@ -6,6 +6,10 @@
"severity": "high",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -29,5 +33,10 @@
]
}
],
"tags": ["apps", "download", "timeout"]
"tags": [
"apps",
"download",
"timeout"
],
"version": "1.1.0"
}
+11 -2
View File
@@ -5,8 +5,12 @@
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
"severity": "high",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -31,5 +35,10 @@
]
}
],
"tags": ["apps", "reboot", "retry", "loop"]
"tags": [
"apps",
"reboot",
"retry",
"loop"
]
}
+24 -2
View File
@@ -5,7 +5,13 @@
"description": "Detects when the app tracking summary reports multiple failed app installations.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"baseConfidence": 75,
"conditions": [
{
@@ -16,10 +22,22 @@
"operator": "gte",
"value": "2",
"required": true
},
{
"signal": "enrollment_failed",
"source": "event_type",
"eventType": "enrollment_failed",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{ "signal": "enrollment_failed", "condition": "exists", "weight": 15 }
{
"signal": "enrollment_failed",
"condition": "exists",
"weight": 15
}
],
"explanation": "The app tracking summary reports 2 or more failed app installations during enrollment. This suggests a systemic issue rather than an isolated app failure.",
"remediation": [
@@ -32,5 +50,9 @@
]
}
],
"tags": ["apps", "summary", "multiple-failures"]
"tags": [
"apps",
"summary",
"multiple-failures"
]
}
+11 -1
View File
@@ -6,6 +6,10 @@
"severity": "warning",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -28,5 +32,11 @@
]
}
],
"tags": ["apps", "performance", "slow-install", "warning"]
"tags": [
"apps",
"performance",
"slow-install",
"warning"
],
"version": "1.1.0"
}
+16 -2
View File
@@ -6,6 +6,10 @@
"severity": "warning",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "correlation",
"baseConfidence": 80,
"conditions": [
@@ -34,7 +38,17 @@
}
],
"relatedDocs": [
{ "title": "Win32 app return codes in Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes" }
{
"title": "Win32 app return codes in Intune",
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
}
],
"tags": ["apps", "enforcement", "exit-code", "unmapped", "resolved"]
"tags": [
"apps",
"enforcement",
"exit-code",
"unmapped",
"resolved"
],
"version": "1.1.0"
}
+20 -4
View File
@@ -5,8 +5,12 @@
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
"severity": "critical",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 90,
"preconditions": [
{
@@ -69,8 +73,20 @@
}
],
"relatedDocs": [
{ "title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes", "url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg" },
{ "title": "Win32 app detection rules in Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules" }
{
"title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes",
"url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg"
},
{
"title": "Win32 app detection rules in Intune",
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
}
],
"tags": ["apps", "detection", "esp", "0x87d1041c", "intune"]
"tags": [
"apps",
"detection",
"esp",
"0x87d1041c",
"intune"
]
}
+66 -4
View File
@@ -5,8 +5,12 @@
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
"severity": "warning",
"category": "apps",
"version": "1.0.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 75,
"preconditions": [
{
@@ -35,6 +39,51 @@
"operator": "regex",
"value": "^0x80073[cd]",
"required": false
},
{
"signal": "appx_esp_category",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "espCategory",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_esp_errorcode",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "espErrorCode",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_top_candidate_package",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "topCandidatePackage",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_candidate_count",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "candidateCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_scan_confidence",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "confidence",
"operator": "exists",
"value": "",
"required": false
}
],
"explanation": "The ESP **{{espCategory}}/Apps** subcategory failed with HRESULT **{{espErrorCode}}** — an error from the AppX/MSIX deployment stack, i.e. a **Microsoft Store / MSIX app**, not a Win32 app. These apps install via the EnterpriseModernAppManagement CSP and never appear in the IME Win32 sidecar logs, which is why all tracked Win32/WinGet apps in this session can look green while ESP still reports an Apps failure.\n\nThe agent scanned the AppXDeploymentServer event log during the failure settle window and identified **{{topCandidatePackageName}}** (package `{{topCandidatePackage}}`) as the most likely culprit — its deployment error matches the ESP failure code ({{candidateCount}} candidate(s) total, confidence: {{confidence}}).\n\n**This is a correlation-based assessment, not a confirmed root cause** — validate against the AppXDeploymentServer/Operational event log on the device or in the diagnostics archive before acting.",
@@ -65,8 +114,21 @@
}
],
"relatedDocs": [
{ "title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes", "url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg" },
{ "title": "Add Microsoft Store apps to Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft" }
{
"title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes",
"url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg"
},
{
"title": "Add Microsoft Store apps to Intune",
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
}
],
"tags": ["apps", "appx", "msix", "store", "esp", "0x80073cf9"]
"tags": [
"apps",
"appx",
"msix",
"store",
"esp",
"0x80073cf9"
]
}
+80
View File
@@ -0,0 +1,80 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-015",
"title": "Required App Never Settled — ESP Completion Deferred",
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
"baseConfidence": 60,
"confidenceThreshold": 55,
"preconditions": [
{
"source": "event_data",
"eventType": "enrollment_complete",
"operator": "not_exists",
"description": "Suppress when the pending app eventually settled and the enrollment completed — a transient deferral is normal."
},
{
"source": "event_data",
"eventType": "enrollment_failed",
"operator": "not_exists",
"description": "Suppress when the session failed explicitly — the app/ESP failure rules already cover that path; this rule targets the never-finishing case."
}
],
"conditions": [
{
"signal": "completion_deferred",
"source": "event_type",
"eventType": "agent_trace",
"dataField": "message",
"operator": "contains",
"value": "completion signal deferred",
"required": true
},
{
"signal": "apps_pending",
"source": "event_data",
"eventType": "app_tracking_summary",
"dataField": "pending",
"operator": "gte",
"value": "1",
"required": true
},
{
"signal": "pending_app_names",
"source": "event_data",
"eventType": "app_tracking_summary",
"dataField": "pendingNames",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{ "signal": "pending_app_names", "condition": "exists", "weight": 5 }
],
"explanation": "The Intune Management Extension wrote its user-session completion line, but the agent observed `{{pending}}` required app(s) still pending (`{{pendingNames}}`) and therefore **deferred the enrollment completion signal** — repeatedly, if this happened across several logons or reboots.\n\nA pending app that never transitions to downloading/installing and never fails is almost always an **assignment or requirement problem**, not an install error:\n\n- requirement rules that are never met on this device (OS/architecture/registry/script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app targeted as *required* for the user ESP that needs something unavailable during provisioning (e.g. a VPN client whose portal sign-in only happens after first logon),\n- user-targeted apps on a device where the user identity never fully registered (see the hybrid user-affinity rule if it also fired).",
"remediation": [
{
"title": "Identify and unblock the pending app",
"steps": [
"Check the app names listed in this finding's evidence (pendingNames) — these are the apps the ESP is waiting for.",
"In Intune, review the app's requirement rules and dependencies; test them on the device (IntuneManagementExtension.log logs each requirement evaluation).",
"Decide whether the app really must block the ESP: removing it from the ESP's 'blocking apps' list (or making it not-required during enrollment) unblocks completion immediately.",
"If the app is user-targeted on a hybrid-joined device, verify the user's Entra affinity first — without a signed-in Entra user, user-targeted policy never applies."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
}
],
"markSessionAsFailedDefault": false,
"tags": ["apps", "esp", "completion", "assignment", "pending"]
}
+24 -4
View File
@@ -5,8 +5,13 @@
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
"severity": "high",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"trigger": "correlation",
"baseConfidence": 60,
"conditions": [
@@ -30,8 +35,16 @@
}
],
"confidenceFactors": [
{ "signal": "app_install_failed", "condition": "count >= 2", "weight": 15 },
{ "signal": "enrollment_failed", "condition": "count >= 1", "weight": 10 }
{
"signal": "app_install_failed",
"condition": "count >= 2",
"weight": 15
},
{
"signal": "enrollment_failed",
"condition": "count >= 1",
"weight": 10
}
],
"confidenceThreshold": 50,
"explanation": "A proxy (explicit or PAC-based) is configured on this device and app content downloads failed (Delivery Optimization timeout or download content error). Proxies can block or throttle downloads from *.delivery.mp.microsoft.com and other Intune CDN endpoints.\n\nThis is a common root cause when app downloads fail consistently on proxy-connected corporate networks. Note this rule only fires when the failure is download-related — generic app install errors behind a proxy do not trigger it.",
@@ -54,5 +67,12 @@
]
}
],
"tags": ["correlation", "apps", "proxy", "network", "download", "root-cause"]
"tags": [
"correlation",
"apps",
"proxy",
"network",
"download",
"root-cause"
]
}
+20 -4
View File
@@ -5,8 +5,12 @@
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
"severity": "warning",
"category": "device",
"version": "2.0.0",
"version": "2.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 55,
"conditions": [
{
@@ -38,8 +42,16 @@
}
],
"confidenceFactors": [
{ "signal": "app_failed", "condition": "exists", "weight": 20 },
{ "signal": "enrollment_failed", "condition": "exists", "weight": 15 }
{
"signal": "app_failed",
"condition": "exists",
"weight": 20
},
{
"signal": "enrollment_failed",
"condition": "exists",
"weight": 15
}
],
"explanation": "Memory usage exceeded 90% in at least 3 performance snapshots during this enrollment — sustained pressure, not a single transient spike. Persistent high memory usage can cause app installation failures, system slowdowns, and ESP timeouts, especially on devices with 8 GB RAM installing several apps concurrently.",
"remediation": [
@@ -53,5 +65,9 @@
]
}
],
"tags": ["device", "memory", "performance"]
"tags": [
"device",
"memory",
"performance"
]
}
+12 -2
View File
@@ -5,8 +5,12 @@
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
"severity": "warning",
"category": "device",
"version": "2.0.0",
"version": "2.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 90,
"conditions": [
{
@@ -52,5 +56,11 @@
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
}
],
"tags": ["device", "os-version", "end-of-support", "windows-11", "windows-10"]
"tags": [
"device",
"os-version",
"end-of-support",
"windows-11",
"windows-10"
]
}
+22 -4
View File
@@ -5,8 +5,13 @@
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
"severity": "high",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:windows_update_failed"
],
"trigger": "single",
"baseConfidence": 70,
"confidenceThreshold": 60,
@@ -65,8 +70,16 @@
}
],
"confidenceFactors": [
{ "signal": "enrollment_failed", "condition": "exists", "weight": 20 },
{ "signal": "reboot_pending", "condition": "exists", "weight": 10 }
{
"signal": "enrollment_failed",
"condition": "exists",
"weight": 20
},
{
"signal": "reboot_pending",
"condition": "exists",
"weight": 10
}
],
"explanation": "A Windows Update failed to install during enrollment: **{{updateTitle}}** (error {{hresult}} {{hresultSymbol}}). Windows can install quality/cumulative updates during OOBE (the \"Getting updates\" screen and the ESP \"Install Windows quality updates during OOBE\" option). When one of these fails mid-enrollment it can leave the device in an inconsistent state, force an unexpected reboot, or block the Enrollment Status Page — a failure mode that is otherwise only discoverable by deep manual digging on the device.",
"remediation": [
@@ -87,5 +100,10 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["device", "windows-update", "oobe", "servicing"]
"tags": [
"device",
"windows-update",
"oobe",
"servicing"
]
}
+15 -3
View File
@@ -5,8 +5,12 @@
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 60,
"confidenceThreshold": 50,
@@ -39,7 +43,11 @@
}
],
"confidenceFactors": [
{ "signal": "reboot_pending", "condition": "exists", "weight": 15 }
{
"signal": "reboot_pending",
"condition": "exists",
"weight": 15
}
],
"explanation": "One or more Windows updates installed during enrollment (e.g. **{{updateTitle}}**). This is normal when Windows Update runs during OOBE, but it extends enrollment time and can trigger a reboot mid-ESP. Track it when correlating enrollment duration or reboot count with a specific cumulative update, or when a device behaves differently after a particular KB landed during provisioning.",
"remediation": [
@@ -53,5 +61,9 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["device", "windows-update", "oobe"]
"tags": [
"device",
"windows-update",
"oobe"
]
}
+16 -3
View File
@@ -5,8 +5,12 @@
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
"severity": "info",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 70,
"confidenceThreshold": 50,
@@ -47,7 +51,11 @@
}
],
"confidenceFactors": [
{ "signal": "wu_channel_blind", "condition": "exists", "weight": 10 }
{
"signal": "wu_channel_blind",
"condition": "exists",
"weight": 10
}
],
"explanation": "The OS build changed from **{{previousBuild}}** to **{{currentBuild}}** across a reboot during enrollment — a Windows update was installed mid-provisioning. This is normal when quality updates run during OOBE, but it adds a reboot and extends enrollment time. If a windows_update_channel_census event is also present, the update installed via a servicing path the Windows Update watcher does not capture yet — its EventID histogram tells which IDs to enable.",
"remediation": [
@@ -61,5 +69,10 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["device", "windows-update", "oobe", "reboot"]
"tags": [
"device",
"windows-update",
"oobe",
"reboot"
]
}
+13 -2
View File
@@ -5,9 +5,13 @@
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts. Knowledge source: docs/agent/autopilot-ztd-diagnostics.md.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 55,
"confidenceThreshold": 50,
@@ -55,5 +59,12 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["device", "ntp", "clock-skew", "tpm", "esp", "known-issue"]
"tags": [
"device",
"ntp",
"clock-skew",
"tpm",
"esp",
"known-issue"
]
}
+35 -2
View File
@@ -5,7 +5,7 @@
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 80,
"preconditions": [
@@ -30,9 +30,35 @@
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "timeoutHours",
"source": "event_data",
"eventType": "session_timeout",
"dataField": "timeoutHours",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "desktop_was_reached",
"source": "event_type",
"eventType": "desktop_arrived",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "all_tracked_apps_completed",
"source": "event_data",
"eventType": "app_tracking_summary",
"dataField": "isAllCompleted",
"operator": "equals",
"value": "true",
"required": false
}
],
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.",
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
"remediation": [
{
"title": "Investigate the silent session",
@@ -43,6 +69,13 @@
"If the device is reachable, collect the agent + IME logs and re-run enrollment, correlating with the timeout timestamp",
"If many sessions time out in the same window, check for a tenant-wide network/connectivity issue or an Intune service incident"
]
},
{
"title": "Desktop reached and apps done, but no completion",
"steps": [
"Sign in as the affected user and run dsregcmd /status — 'SSO State: AzureAdPrt' must be YES; NO means the user never registered with Entra and the Account Setup ESP will loop",
"Verify Entra Connect syncs both the computer object (userCertificate) and the signing-in user, and that the user's UPN suffix is a verified Entra domain"
]
}
],
"tags": ["enrollment", "timeout", "stalled"]
+14 -2
View File
@@ -5,9 +5,14 @@
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:modern_deployment_error"
],
"trigger": "single",
"baseConfidence": 60,
"confidenceThreshold": 55,
@@ -75,5 +80,12 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["enrollment", "tpm", "attestation", "self-deploying", "pre-provisioning", "known-issue"]
"tags": [
"enrollment",
"tpm",
"attestation",
"self-deploying",
"pre-provisioning",
"known-issue"
]
}
+12 -2
View File
@@ -5,9 +5,13 @@
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 60,
"confidenceThreshold": 55,
@@ -74,5 +78,11 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["enrollment", "mdm", "known-issue", "entra-id", "hybrid-join"]
"tags": [
"enrollment",
"mdm",
"known-issue",
"entra-id",
"hybrid-join"
]
}
+32 -6
View File
@@ -5,9 +5,13 @@
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276). Knowledge source: docs/agent/autopilot-ztd-diagnostics.md (error-code map).",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "correlation",
"baseConfidence": 25,
"confidenceThreshold": 60,
@@ -59,10 +63,26 @@
}
],
"confidenceFactors": [
{ "signal": "e_fail_esp_failure", "condition": "exists", "weight": 40 },
{ "signal": "e_fail_esp_advisory", "condition": "exists", "weight": 35 },
{ "signal": "e_fail_modern_deployment", "condition": "exists", "weight": 40 },
{ "signal": "e_fail_error_detected", "condition": "exists", "weight": 35 }
{
"signal": "e_fail_esp_failure",
"condition": "exists",
"weight": 40
},
{
"signal": "e_fail_esp_advisory",
"condition": "exists",
"weight": 35
},
{
"signal": "e_fail_modern_deployment",
"condition": "exists",
"weight": 40
},
{
"signal": "e_fail_error_detected",
"condition": "exists",
"weight": 35
}
],
"explanation": "This is a **Microsoft Entra hybrid join** deployment that hit error **0x80004005** — a documented, build-dependent Autopilot known issue where hybrid deployments time out.\n\nThe issue is fixed by Windows cumulative updates:\n\n- Windows 11 **25H2**: KB5065789 — fixed from build **26200.6725**\n- Windows 11 **24H2**: KB5065426 — fixed from build **26100.6584**\n- Windows 11 **23H2**: KB5070312 — fixed from build **22631.6276**\n\nCompare this session's OS build (see the Session Info card / os_info event) against the fixed-build threshold for its version. If the device booted with an older build, updating the deployment image (or installing quality updates during OOBE) resolves the timeout. Note that 0x80004005 is a generic failure code (E_FAIL) — if the build is already at or above the fixed threshold, investigate the Intune Connector for Active Directory and domain-controller connectivity instead.",
"remediation": [
@@ -83,5 +103,11 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["enrollment", "hybrid-join", "known-issue", "windows-update", "esp"]
"tags": [
"enrollment",
"hybrid-join",
"known-issue",
"windows-update",
"esp"
]
}
+21 -4
View File
@@ -5,8 +5,12 @@
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
"severity": "high",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 50,
"conditions": [
{
@@ -20,8 +24,16 @@
}
],
"confidenceFactors": [
{ "signal": "long_esp", "condition": "phase_duration > 1800", "weight": 40 },
{ "signal": "very_long_esp", "condition": "phase_duration > 3600", "weight": 15 }
{
"signal": "long_esp",
"condition": "phase_duration > 1800",
"weight": 40
},
{
"signal": "very_long_esp",
"condition": "phase_duration > 3600",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "The ESP DeviceSetup phase took more than 30 minutes. This indicates a blocking app is stuck installing, downloading very slowly, or has entered a retry loop. A duration beyond 60 minutes raises the confidence further — at that point the enrollment is almost certainly held by a single stuck blocking item rather than ordinary slowness.",
@@ -35,5 +47,10 @@
]
}
],
"tags": ["esp", "blocking", "timeout", "duration"]
"tags": [
"esp",
"blocking",
"timeout",
"duration"
]
}
+11 -2
View File
@@ -5,9 +5,13 @@
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
"severity": "high",
"category": "esp",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
@@ -54,5 +58,10 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["esp", "certificates", "subcategory", "silent-failure"]
"tags": [
"esp",
"certificates",
"subcategory",
"silent-failure"
]
}
+23 -4
View File
@@ -5,9 +5,13 @@
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
"severity": "warning",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 70,
"conditions": [
@@ -47,8 +51,16 @@
}
],
"confidenceFactors": [
{ "signal": "multiple_reboot_uris", "condition": "exists", "weight": 10 },
{ "signal": "omadm_reboot_flag_set", "condition": "exists", "weight": 15 }
{
"signal": "multiple_reboot_uris",
"condition": "exists",
"weight": 10
},
{
"signal": "omadm_reboot_flag_set",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
@@ -81,5 +93,12 @@
}
],
"markSessionAsFailedDefault": false,
"tags": ["esp", "reboot", "coalescing", "second-sign-in", "policy-attribution", "user-experience"]
"tags": [
"esp",
"reboot",
"coalescing",
"second-sign-in",
"policy-attribution",
"user-experience"
]
}
+23 -4
View File
@@ -5,8 +5,12 @@
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
"severity": "info",
"category": "esp",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 85,
"conditions": [
{
@@ -71,8 +75,16 @@
}
],
"confidenceFactors": [
{ "signal": "retry", "condition": "exists", "weight": 10 },
{ "signal": "likelyCulpritApps", "condition": "exists", "weight": 5 }
{
"signal": "retry",
"condition": "exists",
"weight": 10
},
{
"signal": "likelyCulpritApps",
"condition": "exists",
"weight": 5
}
],
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
"remediation": [
@@ -86,5 +98,12 @@
]
}
],
"tags": ["esp", "recovery", "try-again", "retry", "continue-anyway", "enrollment"]
"tags": [
"esp",
"recovery",
"try-again",
"retry",
"continue-anyway",
"enrollment"
]
}
+11 -2
View File
@@ -7,7 +7,11 @@
"category": "identity",
"isCommunity": true,
"enabled": false,
"version": "1.2.0",
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"version": "1.3.0",
"baseConfidence": 75,
"conditions": [
{
@@ -50,7 +54,12 @@
]
}
],
"tags": ["identity", "certificates", "machine-store", "subject-check"],
"tags": [
"identity",
"certificates",
"machine-store",
"subject-check"
],
"templateVariables": [
{
"name": "cert_subject",
+11 -2
View File
@@ -5,9 +5,13 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 60,
"conditions": [
@@ -81,5 +85,10 @@
}
],
"relatedDocs": [],
"tags": ["identity", "security", "local-admin", "bypass-detection"]
"tags": [
"identity",
"security",
"local-admin",
"bypass-detection"
]
}
+17 -3
View File
@@ -5,9 +5,13 @@
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
"severity": "critical",
"category": "security",
"version": "1.0.1",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 90,
"conditions": [
@@ -31,7 +35,11 @@
}
],
"confidenceFactors": [
{ "signal": "kev_matches", "condition": "exists", "weight": 10 }
{
"signal": "kev_matches",
"condition": "exists",
"weight": 10
}
],
"confidenceThreshold": 80,
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
@@ -64,5 +72,11 @@
"url": "https://nvd.nist.gov/"
}
],
"tags": ["vulnerability", "cve", "kev", "critical", "security"]
"tags": [
"vulnerability",
"cve",
"kev",
"critical",
"security"
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ID-004",
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
"severity": "high",
"category": "identity",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"],
"baseConfidence": 60,
"confidenceThreshold": 75,
"preconditions": [
{
"source": "event_data",
"eventType": "enrollment_complete",
"operator": "not_exists",
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
}
],
"conditions": [
{
"signal": "hybrid_login_overdue",
"source": "event_type",
"eventType": "hybrid_login_pending",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "user_desktop_reached",
"source": "event_type",
"eventType": "desktop_arrived",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "placeholder_still_active",
"source": "event_type",
"eventType": "aad_placeholder_user_detected",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{ "signal": "hybrid_login_pending", "condition": "count >= 2", "weight": 20 },
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
{ "signal": "aad_placeholder_user_detected", "condition": "count >= 3", "weight": 10 }
],
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.",
"remediation": [
{
"title": "Verify user affinity on the device",
"steps": [
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
]
},
{
"title": "Fix the identity pipeline",
"steps": [
"Entra Connect: confirm BOTH the computer OU and the user OU are in the sync scope, and the computer object's userCertificate attribute is being exported.",
"Verify the user's UPN suffix is a verified domain in Entra ID (no .local / mismatched suffix).",
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
]
}
],
"relatedDocs": [
{
"title": "Autopilot Monitor: Hybrid join — late device registration",
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
},
{
"title": "Microsoft Learn: Troubleshoot hybrid-joined devices",
"url": "https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-hybrid-join-windows-current"
}
],
"markSessionAsFailedDefault": false,
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
}
+17 -3
View File
@@ -5,8 +5,12 @@
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
"severity": "warning",
"category": "apps",
"version": "1.0.1",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 75,
"conditions": [
{
@@ -28,7 +32,11 @@
}
],
"confidenceFactors": [
{ "signal": "error_code_present", "condition": "exists", "weight": 15 }
{
"signal": "error_code_present",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 40,
"explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.",
@@ -44,5 +52,11 @@
]
}
],
"tags": ["apps", "office", "microsoft-365-apps", "click-to-run", "install-failure"]
"tags": [
"apps",
"office",
"microsoft-365-apps",
"click-to-run",
"install-failure"
]
}
+32 -6
View File
@@ -5,8 +5,12 @@
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
"severity": "warning",
"category": "security",
"version": "3.0.0",
"version": "3.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"preconditions": [
{
@@ -83,10 +87,26 @@
}
],
"confidenceFactors": [
{ "signal": "ca2023_notfound", "condition": "exists", "weight": 20 },
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 },
{ "signal": "db_ca2023_firmware_missing", "condition": "exists", "weight": 20 },
{ "signal": "kek_2023_missing", "condition": "exists", "weight": 10 }
{
"signal": "ca2023_notfound",
"condition": "exists",
"weight": 20
},
{
"signal": "ca2023_error",
"condition": "exists",
"weight": 10
},
{
"signal": "db_ca2023_firmware_missing",
"condition": "exists",
"weight": 20
},
{
"signal": "kek_2023_missing",
"condition": "exists",
"weight": 10
}
],
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
"remediation": [
@@ -112,5 +132,11 @@
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
}
],
"tags": ["security", "secureboot", "certificate", "ca2023", "compliance"]
"tags": [
"security",
"secureboot",
"certificate",
"ca2023",
"compliance"
]
}
+12 -2
View File
@@ -5,8 +5,12 @@
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
"severity": "high",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 90,
"conditions": [
@@ -45,5 +49,11 @@
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
}
],
"tags": ["security", "autologon", "winlogon", "credentials", "plaintext-password"]
"tags": [
"security",
"autologon",
"winlogon",
"credentials",
"plaintext-password"
]
}
+12 -2
View File
@@ -5,8 +5,12 @@
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
@@ -55,7 +59,13 @@
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"tags": ["security", "autologon", "winlogon", "kiosk", "allow-list"],
"tags": [
"security",
"autologon",
"winlogon",
"kiosk",
"allow-list"
],
"templateVariables": [
{
"name": "allowed_autologon_users",
+12 -2
View File
@@ -5,8 +5,12 @@
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
"severity": "warning",
"category": "security",
"version": "2.1.0",
"version": "2.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -34,5 +38,11 @@
]
}
],
"tags": ["security", "provisioning-package", "ppkg", "allow-list", "tamper"]
"tags": [
"security",
"provisioning-package",
"ppkg",
"allow-list",
"tamper"
]
}
+12 -2
View File
@@ -5,8 +5,12 @@
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
"severity": "warning",
"category": "security",
"version": "2.1.0",
"version": "2.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -41,7 +45,13 @@
]
}
],
"tags": ["security", "provisioning-package", "ppkg", "allow-list", "template"],
"tags": [
"security",
"provisioning-package",
"ppkg",
"allow-list",
"template"
],
"templateVariables": [
{
"name": "allowed_packages",
+12 -2
View File
@@ -5,8 +5,12 @@
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -32,5 +36,11 @@
]
}
],
"tags": ["security", "provisioning-package", "ppkg", "truncated", "coverage"]
"tags": [
"security",
"provisioning-package",
"ppkg",
"truncated",
"coverage"
]
}
+17 -3
View File
@@ -5,9 +5,13 @@
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
@@ -41,7 +45,11 @@
}
],
"confidenceFactors": [
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
{
"signal": "physical_device",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 60,
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
@@ -74,5 +82,11 @@
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
"tags": [
"security",
"secureboot",
"firmware",
"uefi",
"compliance"
]
}
+474 -37
View File
@@ -7,8 +7,12 @@
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
"severity": "warning",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 60,
"conditions": [
{
@@ -48,8 +52,13 @@
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
"severity": "high",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -92,6 +101,10 @@
"severity": "high",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -118,7 +131,8 @@
"tags": [
"apps",
"dependency"
]
],
"version": "1.1.0"
},
{
"ruleId": "ANALYZE-APP-004",
@@ -126,8 +140,13 @@
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
"severity": "critical",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"baseConfidence": 75,
"conditions": [
{
@@ -200,6 +219,10 @@
"severity": "high",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -227,7 +250,8 @@
"apps",
"download",
"timeout"
]
],
"version": "1.1.0"
},
{
"ruleId": "ANALYZE-APP-006",
@@ -235,8 +259,12 @@
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
"severity": "high",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -274,7 +302,13 @@
"description": "Detects when the app tracking summary reports multiple failed app installations.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"baseConfidence": 75,
"conditions": [
{
@@ -285,6 +319,14 @@
"operator": "gte",
"value": "2",
"required": true
},
{
"signal": "enrollment_failed",
"source": "event_type",
"eventType": "enrollment_failed",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
@@ -318,6 +360,10 @@
"severity": "warning",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"conditions": [
{
@@ -345,7 +391,8 @@
"performance",
"slow-install",
"warning"
]
],
"version": "1.1.0"
},
{
"ruleId": "ANALYZE-APP-010",
@@ -425,6 +472,10 @@
"severity": "warning",
"category": "apps",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "correlation",
"baseConfidence": 80,
"conditions": [
@@ -464,7 +515,8 @@
"exit-code",
"unmapped",
"resolved"
]
],
"version": "1.1.0"
},
{
"ruleId": "ANALYZE-APP-013",
@@ -472,8 +524,12 @@
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
"severity": "critical",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 90,
"preconditions": [
{
@@ -559,8 +615,12 @@
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
"severity": "warning",
"category": "apps",
"version": "1.0.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 75,
"preconditions": [
{
@@ -589,6 +649,51 @@
"operator": "regex",
"value": "^0x80073[cd]",
"required": false
},
{
"signal": "appx_esp_category",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "espCategory",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_esp_errorcode",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "espErrorCode",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_top_candidate_package",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "topCandidatePackage",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_candidate_count",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "candidateCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "appx_scan_confidence",
"source": "event_data",
"eventType": "esp_appx_failure_analysis",
"dataField": "confidence",
"operator": "exists",
"value": "",
"required": false
}
],
"explanation": "The ESP **{{espCategory}}/Apps** subcategory failed with HRESULT **{{espErrorCode}}** — an error from the AppX/MSIX deployment stack, i.e. a **Microsoft Store / MSIX app**, not a Win32 app. These apps install via the EnterpriseModernAppManagement CSP and never appear in the IME Win32 sidecar logs, which is why all tracked Win32/WinGet apps in this session can look green while ESP still reports an Apps failure.\n\nThe agent scanned the AppXDeploymentServer event log during the failure settle window and identified **{{topCandidatePackageName}}** (package `{{topCandidatePackage}}`) as the most likely culprit — its deployment error matches the ESP failure code ({{candidateCount}} candidate(s) total, confidence: {{confidence}}).\n\n**This is a correlation-based assessment, not a confirmed root cause** — validate against the AppXDeploymentServer/Operational event log on the device or in the diagnostics archive before acting.",
@@ -637,14 +742,108 @@
"0x80073cf9"
]
},
{
"ruleId": "ANALYZE-APP-015",
"title": "Required App Never Settled — ESP Completion Deferred",
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
"baseConfidence": 60,
"confidenceThreshold": 55,
"preconditions": [
{
"source": "event_data",
"eventType": "enrollment_complete",
"operator": "not_exists",
"description": "Suppress when the pending app eventually settled and the enrollment completed — a transient deferral is normal."
},
{
"source": "event_data",
"eventType": "enrollment_failed",
"operator": "not_exists",
"description": "Suppress when the session failed explicitly — the app/ESP failure rules already cover that path; this rule targets the never-finishing case."
}
],
"conditions": [
{
"signal": "completion_deferred",
"source": "event_type",
"eventType": "agent_trace",
"dataField": "message",
"operator": "contains",
"value": "completion signal deferred",
"required": true
},
{
"signal": "apps_pending",
"source": "event_data",
"eventType": "app_tracking_summary",
"dataField": "pending",
"operator": "gte",
"value": "1",
"required": true
},
{
"signal": "pending_app_names",
"source": "event_data",
"eventType": "app_tracking_summary",
"dataField": "pendingNames",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{
"signal": "pending_app_names",
"condition": "exists",
"weight": 5
}
],
"explanation": "The Intune Management Extension wrote its user-session completion line, but the agent observed `{{pending}}` required app(s) still pending (`{{pendingNames}}`) and therefore **deferred the enrollment completion signal** — repeatedly, if this happened across several logons or reboots.\n\nA pending app that never transitions to downloading/installing and never fails is almost always an **assignment or requirement problem**, not an install error:\n\n- requirement rules that are never met on this device (OS/architecture/registry/script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app targeted as *required* for the user ESP that needs something unavailable during provisioning (e.g. a VPN client whose portal sign-in only happens after first logon),\n- user-targeted apps on a device where the user identity never fully registered (see the hybrid user-affinity rule if it also fired).",
"remediation": [
{
"title": "Identify and unblock the pending app",
"steps": [
"Check the app names listed in this finding's evidence (pendingNames) — these are the apps the ESP is waiting for.",
"In Intune, review the app's requirement rules and dependencies; test them on the device (IntuneManagementExtension.log logs each requirement evaluation).",
"Decide whether the app really must block the ESP: removing it from the ESP's 'blocking apps' list (or making it not-required during enrollment) unblocks completion immediately.",
"If the app is user-targeted on a hybrid-joined device, verify the user's Entra affinity first — without a signed-in Entra user, user-targeted policy never applies."
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"apps",
"esp",
"completion",
"assignment",
"pending"
]
},
{
"ruleId": "ANALYZE-CORR-003",
"title": "Proxy Configuration Causing Download Failure",
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
"severity": "high",
"category": "apps",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:app_install_failed"
],
"trigger": "correlation",
"baseConfidence": 60,
"conditions": [
@@ -752,8 +951,12 @@
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
"severity": "warning",
"category": "device",
"version": "2.0.0",
"version": "2.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 55,
"conditions": [
{
@@ -820,8 +1023,12 @@
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
"severity": "warning",
"category": "device",
"version": "2.0.0",
"version": "2.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 90,
"conditions": [
{
@@ -881,8 +1088,13 @@
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
"severity": "high",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:windows_update_failed"
],
"trigger": "single",
"baseConfidence": 70,
"confidenceThreshold": 60,
@@ -984,8 +1196,12 @@
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 60,
"confidenceThreshold": 50,
@@ -1048,8 +1264,12 @@
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
"severity": "info",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 70,
"confidenceThreshold": 50,
@@ -1121,9 +1341,13 @@
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts. Knowledge source: docs/agent/autopilot-ztd-diagnostics.md.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 55,
"confidenceThreshold": 50,
@@ -1268,7 +1492,7 @@
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 80,
"preconditions": [
@@ -1293,9 +1517,35 @@
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "timeoutHours",
"source": "event_data",
"eventType": "session_timeout",
"dataField": "timeoutHours",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "desktop_was_reached",
"source": "event_type",
"eventType": "desktop_arrived",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "all_tracked_apps_completed",
"source": "event_data",
"eventType": "app_tracking_summary",
"dataField": "isAllCompleted",
"operator": "equals",
"value": "true",
"required": false
}
],
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.",
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
"remediation": [
{
"title": "Investigate the silent session",
@@ -1306,6 +1556,13 @@
"If the device is reachable, collect the agent + IME logs and re-run enrollment, correlating with the timeout timestamp",
"If many sessions time out in the same window, check for a tenant-wide network/connectivity issue or an Intune service incident"
]
},
{
"title": "Desktop reached and apps done, but no completion",
"steps": [
"Sign in as the affected user and run dsregcmd /status — 'SSO State: AzureAdPrt' must be YES; NO means the user never registered with Entra and the Account Setup ESP will loop",
"Verify Entra Connect syncs both the computer object (userCertificate) and the signing-in user, and that the user's UPN suffix is a verified Entra domain"
]
}
],
"tags": [
@@ -1320,9 +1577,14 @@
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:modern_deployment_error"
],
"trigger": "single",
"baseConfidence": 60,
"confidenceThreshold": 55,
@@ -1405,9 +1667,13 @@
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 60,
"confidenceThreshold": 55,
@@ -1488,9 +1754,13 @@
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276). Knowledge source: docs/agent/autopilot-ztd-diagnostics.md (error-code map).",
"severity": "high",
"category": "enrollment",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "correlation",
"baseConfidence": 25,
"confidenceThreshold": 60,
@@ -1596,8 +1866,12 @@
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
"severity": "high",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 50,
"conditions": [
{
@@ -1647,9 +1921,13 @@
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
"severity": "high",
"category": "esp",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
@@ -1791,9 +2069,13 @@
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
"severity": "warning",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 70,
"conditions": [
@@ -1890,8 +2172,12 @@
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
"severity": "info",
"category": "esp",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 85,
"conditions": [
{
@@ -1996,7 +2282,11 @@
"category": "identity",
"isCommunity": true,
"enabled": false,
"version": "1.2.0",
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"version": "1.3.0",
"baseConfidence": 75,
"conditions": [
{
@@ -2062,9 +2352,13 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 60,
"conditions": [
@@ -2151,9 +2445,13 @@
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
"severity": "critical",
"category": "security",
"version": "1.0.1",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 90,
"conditions": [
@@ -2222,14 +2520,125 @@
"security"
]
},
{
"ruleId": "ANALYZE-ID-004",
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
"severity": "high",
"category": "identity",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
"evaluateOn": [
"enrollment_end",
"on_event:hybrid_login_pending"
],
"baseConfidence": 60,
"confidenceThreshold": 75,
"preconditions": [
{
"source": "event_data",
"eventType": "enrollment_complete",
"operator": "not_exists",
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
}
],
"conditions": [
{
"signal": "hybrid_login_overdue",
"source": "event_type",
"eventType": "hybrid_login_pending",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "user_desktop_reached",
"source": "event_type",
"eventType": "desktop_arrived",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "placeholder_still_active",
"source": "event_type",
"eventType": "aad_placeholder_user_detected",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{
"signal": "hybrid_login_pending",
"condition": "count >= 2",
"weight": 20
},
{
"signal": "hybrid_login_pending",
"condition": "count >= 3",
"weight": 10
},
{
"signal": "aad_placeholder_user_detected",
"condition": "count >= 3",
"weight": 10
}
],
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.",
"remediation": [
{
"title": "Verify user affinity on the device",
"steps": [
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
]
},
{
"title": "Fix the identity pipeline",
"steps": [
"Entra Connect: confirm BOTH the computer OU and the user OU are in the sync scope, and the computer object's userCertificate attribute is being exported.",
"Verify the user's UPN suffix is a verified domain in Entra ID (no .local / mismatched suffix).",
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
]
}
],
"relatedDocs": [
{
"title": "Autopilot Monitor: Hybrid join — late device registration",
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
},
{
"title": "Microsoft Learn: Troubleshoot hybrid-joined devices",
"url": "https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-hybrid-join-windows-current"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"identity",
"hybrid-join",
"entra-id",
"user-affinity",
"esp"
]
},
{
"ruleId": "ANALYZE-OFFICE-001",
"title": "Microsoft 365 Apps Install Failed",
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
"severity": "warning",
"category": "apps",
"version": "1.0.1",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 75,
"conditions": [
{
@@ -2285,8 +2694,12 @@
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
"severity": "warning",
"category": "security",
"version": "3.0.0",
"version": "3.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 70,
"preconditions": [
{
@@ -2422,8 +2835,12 @@
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
"severity": "high",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 90,
"conditions": [
@@ -2476,8 +2893,12 @@
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 80,
"conditions": [
@@ -2550,8 +2971,12 @@
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
"severity": "warning",
"category": "security",
"version": "2.1.0",
"version": "2.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -2593,8 +3018,12 @@
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
"severity": "warning",
"category": "security",
"version": "2.1.0",
"version": "2.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -2653,8 +3082,12 @@
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 80,
"conditions": [
{
@@ -2694,9 +3127,13 @@
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
+21
View File
@@ -128,6 +128,27 @@
"oobe"
]
},
{
"ruleId": "GATHER-DEVICE-006",
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
"category": "device",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": false,
"collectorType": "command_allowlisted",
"target": "dsregcmd /status",
"trigger": "on_event",
"triggerEventType": "hybrid_login_pending",
"outputEventType": "gather_dsregcmd_status",
"outputSeverity": "Info",
"tags": [
"device",
"entra",
"join",
"hybrid"
]
},
{
"ruleId": "GATHER-ENROLL-001",
"title": "MDM enrollment diagnostic events",
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../schema/gather-rule.schema.json",
"ruleId": "GATHER-DEVICE-006",
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
"category": "device",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": false,
"collectorType": "command_allowlisted",
"target": "dsregcmd /status",
"trigger": "on_event",
"triggerEventType": "hybrid_login_pending",
"outputEventType": "gather_dsregcmd_status",
"outputSeverity": "Info",
"tags": ["device", "entra", "join", "hybrid"]
}
+11 -1
View File
@@ -272,5 +272,15 @@
"Format-Volume",
"Set-ExecutionPolicy"
],
"maxCommandLength": 2000
"maxCommandLength": 2000,
"blockedInterimTriggerEventTypes": [
"performance_snapshot",
"agent_metrics_snapshot",
"download_progress",
"network_state_change",
"network_connectivity_check",
"log_entry",
"agent_trace",
"stall_probe_check"
]
}
+10
View File
@@ -75,6 +75,16 @@
"enum": ["single", "correlation"],
"default": "single"
},
"evaluateOn": {
"type": "array",
"description": "When this rule is evaluated. Absent = [\"enrollment_end\"] (terminal-only, the historical behavior). Interim triggers let the rule fire before the session is terminal: \"whiteglove_sealed\" (first genuine WhiteGlove seal) and \"on_event:<eventType>\" (an ingest batch contained that event type). High-frequency telemetry event types are HARD-BLOCKED as on_event triggers (guardrails blockedInterimTriggerEventTypes): the backend rejects them on save and the runtime ignores them. Interim runs suppress markSessionAsFailed and record no stats; findings notify once per (session, rule). Interim-enabled rules need monotonic conditions — a not_exists precondition on enrollment_complete/enrollment_failed/session_timeout passes trivially mid-run.",
"items": {
"type": "string",
"pattern": "^(enrollment_end|whiteglove_sealed|on_event:[a-z0-9_]{1,128})$"
},
"minItems": 1,
"uniqueItems": true
},
"conditions": {
"type": "array",
"description": "Conditions evaluated against the event stream. All required conditions must match for the rule to fire.",
+6
View File
@@ -159,6 +159,12 @@ export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
${flatArray(guardrails.blockedCommandPatterns)}
];
// High-frequency telemetry event types hard-blocked as analyze-rule evaluateOn
// on_event triggers (backend rejects on save, runtime ignores them).
export const BLOCKED_INTERIM_TRIGGER_EVENT_TYPES: readonly string[] = [
${flatArray(guardrails.blockedInterimTriggerEventTypes ?? [])}
];
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
`;
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
/// <summary>
/// Completeness tripwire for <see cref="RemoteConfigMerger"/>: the merger hand-maps
/// <see cref="AgentConfigResponse"/> fields onto <c>AgentConfiguration</c>, and
/// historically new tenant knobs were forgotten there and silently ignored. Every public
/// property of <see cref="AgentConfigResponse"/> must be classified in exactly one of the
/// two lists below. A new property fails this test until a human either wires it through
/// <see cref="RemoteConfigMerger.Merge"/> or documents where else it is consumed.
/// </summary>
public sealed class RemoteConfigMergerCompletenessTests
{
/// <summary>
/// Fields <see cref="RemoteConfigMerger.Merge"/> maps onto <c>AgentConfiguration</c>.
/// Keep in sync with the Merge body — the test only proves classification coverage,
/// the value-level mapping itself is covered by <see cref="RemoteConfigMergerTests"/>.
/// </summary>
private static readonly string[] MergedByMerger =
{
nameof(AgentConfigResponse.SelfDestructOnComplete),
nameof(AgentConfigResponse.KeepLogFile),
nameof(AgentConfigResponse.RebootOnComplete),
nameof(AgentConfigResponse.EnableGeoLocation),
nameof(AgentConfigResponse.LogLevel),
nameof(AgentConfigResponse.RebootDelaySeconds),
nameof(AgentConfigResponse.ShowEnrollmentSummary),
nameof(AgentConfigResponse.EnrollmentSummaryTimeoutSeconds),
nameof(AgentConfigResponse.EnrollmentSummaryBrandingImageUrl),
nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
nameof(AgentConfigResponse.NtpServer),
nameof(AgentConfigResponse.EnableTimezoneAutoSet),
nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
nameof(AgentConfigResponse.DiagnosticsUploadMode),
nameof(AgentConfigResponse.DiagnosticsLogPaths),
nameof(AgentConfigResponse.SendTraceEvents),
nameof(AgentConfigResponse.EnableEspContinueAnywayObservation),
nameof(AgentConfigResponse.UnrestrictedMode),
nameof(AgentConfigResponse.EnableImeMatchLog),
nameof(AgentConfigResponse.EnableGatherRuleDebugLog),
nameof(AgentConfigResponse.UploadIntervalSeconds),
nameof(AgentConfigResponse.MaxBatchSize),
nameof(AgentConfigResponse.MaxAuthFailures),
nameof(AgentConfigResponse.AuthFailureTimeoutMinutes),
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
nameof(AgentConfigResponse.Collectors),
};
/// <summary>
/// Fields deliberately NOT mapped by the merger because another component consumes them
/// directly from the remote config. One-line justification per entry — verify the
/// consumer before classifying a new field here.
/// </summary>
private static readonly string[] IntentionallyNotMerged =
{
// Schema/version metadata — logged and cached by RemoteConfigService, not a runtime knob.
nameof(AgentConfigResponse.ConfigVersion),
// Consumed by DefaultComponentFactory → GatherRuleExecutorHost (rules run against the raw remote list).
nameof(AgentConfigResponse.GatherRules),
// Consumed by DefaultComponentFactory → ImeLogHost (pattern list handed to ImeLogTracker).
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.
nameof(AgentConfigResponse.LatestAgentExeSha256),
// Self-updater force-update downgrade gate — BinaryIntegrityVerifier / Program update path.
nameof(AgentConfigResponse.AllowAgentDowngrade),
// Control-channel block signal — RemoteConfigService/ServerControlPlane; stripped from the disk cache.
nameof(AgentConfigResponse.DeviceBlocked),
// Control-channel kill signal — ServerControlPlane / Program.Guards; live fetch only.
nameof(AgentConfigResponse.DeviceKillSignal),
// Temporary-block expiry accompanying DeviceBlocked — ServerControlPlane.
nameof(AgentConfigResponse.UnblockAt),
// Endpoint migration — EndpointMigration/RemoteConfigService re-run the fetch, live fetch only.
nameof(AgentConfigResponse.MigrateToApiBaseUrl),
};
[Fact]
public void Every_AgentConfigResponse_property_is_classified_exactly_once()
{
var properties = typeof(AgentConfigResponse)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
var merged = new HashSet<string>(MergedByMerger, StringComparer.Ordinal);
var notMerged = new HashSet<string>(IntentionallyNotMerged, StringComparer.Ordinal);
var inBoth = merged.Intersect(notMerged, StringComparer.Ordinal).ToArray();
Assert.True(
inBoth.Length == 0,
$"Fields listed in BOTH classification lists: [{string.Join(", ", inBoth)}].");
var unclassified = properties
.Where(p => !merged.Contains(p) && !notMerged.Contains(p))
.ToArray();
Assert.True(
unclassified.Length == 0,
"New AgentConfigResponse field(s) without a classification: " +
$"[{string.Join(", ", unclassified)}]. Either map them in RemoteConfigMerger.Merge " +
"(+ MergedByMerger here) or verify the consuming component and add them to " +
"IntentionallyNotMerged with a justification comment.");
// Stale entries (renamed/removed properties) must be cleaned up too.
var known = new HashSet<string>(properties, StringComparer.Ordinal);
var stale = merged.Concat(notMerged).Where(n => !known.Contains(n)).ToArray();
Assert.True(
stale.Length == 0,
$"Classification lists reference non-existent AgentConfigResponse properties: [{string.Join(", ", stale)}].");
}
}
}
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.True(ok);
Assert.NotNull(entry);
Assert.Equal(
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
entry.Timestamp);
@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// Enforces the comment-only ordering invariants of <see cref="DefaultComponentFactory"/> by
/// building the REAL host list (construction only — no host is started) and asserting the
/// relative positions of the order-sensitive host types. The orchestrator starts hosts in
/// list order, so list order IS start order:
/// <list type="bullet">
/// <item>DeviceInfoHost before EspAndHelloHost — L9 (delta review 2026-07-02): DeviceInfoHost
/// must subscribe to SignalPosted before EspAndHelloHost.Start can post the registry-backfill
/// EspPhaseChanged(DeviceSetup), or the enrollment-start re-collect never runs.</item>
/// <item>OsBuildChangeHost before WindowsUpdateWatcherHost — the WU channel census reads
/// OsBuildChangeHost.BuildChanged at its own start.</item>
/// <item>DeliveryOptimizationHost before OfficeInstallDetectorHost — the DO host must have
/// subscribed to the Office process-start signal before the watcher starts, or an install
/// already in flight misses its wake.</item>
/// <item>DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop
/// host's RequestResetForRealUserSwitch callback, so the target must exist first.</item>
/// </list>
/// </summary>
public sealed class DefaultComponentFactoryOrderingTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
/// <summary>
/// Build the production host list with default remote config (all order-sensitive hosts
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
/// virtual clock, no network metrics, no telemetry spool.
/// </summary>
private CollectorSurfaces CreateSurfaces()
{
var factory = new DefaultComponentFactory(
agentConfig: new AgentConfiguration
{
ApiBaseUrl = "https://example",
TenantId = "t",
SessionId = "s",
},
remoteConfig: new AgentConfigResponse
{
Collectors = CollectorConfiguration.CreateDefault(),
},
networkMetrics: null,
agentVersion: "test",
stateDirectory: _tmp.Path,
startupEventGate: null);
return factory.CreateCollectorHosts(
sessionId: "s",
tenantId: "t",
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
whiteGloveSealingPatternIds: Array.Empty<string>(),
ingress: new FakeSignalIngressSink(),
clock: new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)),
telemetrySpool: null,
timelineEvents: null);
}
private static int IndexOf<THost>(IReadOnlyList<ICollectorHost> hosts) where THost : ICollectorHost
{
var indices = hosts
.Select((host, index) => (host, index))
.Where(t => t.host is THost)
.Select(t => t.index)
.ToArray();
Assert.True(
indices.Length == 1,
$"Expected exactly one {typeof(THost).Name} in the host list, found {indices.Length}. " +
$"Hosts: [{string.Join(", ", hosts.Select(h => h.GetType().Name))}]");
return indices[0];
}
private static void AssertBefore<TFirst, TSecond>(IReadOnlyList<ICollectorHost> hosts, string reason)
where TFirst : ICollectorHost
where TSecond : ICollectorHost
{
var first = IndexOf<TFirst>(hosts);
var second = IndexOf<TSecond>(hosts);
Assert.True(
first < second,
$"{typeof(TFirst).Name} (index {first}) must come before {typeof(TSecond).Name} (index {second}): {reason}");
}
[Fact]
public void Order_sensitive_hosts_keep_their_documented_relative_order()
{
var surfaces = CreateSurfaces();
var hosts = surfaces.Hosts;
try
{
AssertBefore<DeviceInfoHost, EspAndHelloHost>(
hosts,
"L9 — DeviceInfoHost must subscribe to SignalPosted before EspAndHelloHost's " +
"registry backfill can post EspPhaseChanged(DeviceSetup).");
AssertBefore<OsBuildChangeHost, WindowsUpdateWatcherHost>(
hosts,
"the Windows Update channel census reads OsBuildChangeHost.BuildChanged at its own start.");
AssertBefore<DeliveryOptimizationHost, OfficeInstallDetectorHost>(
hosts,
"the DO host must subscribe to the Office process-start signal before the watcher starts.");
AssertBefore<DesktopArrivalHost, AadJoinHost>(
hosts,
"AadJoinHost wires its onRealUserJoined callback to the already-constructed DesktopArrivalHost.");
// Closure-latch sanity (factory lines ~139/297): the EspAndHelloHost probes close
// over the ImeLogHost reference, which is assigned during the same factory call —
// the host must therefore exist in the returned list.
Assert.Equal(1, hosts.Count(h => h is ImeLogHost));
}
finally
{
foreach (var host in hosts)
{
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
}
}
}
}
}
@@ -420,6 +420,93 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
sut.Stop();
}
// ================================================== Branch (a2): snapshot schema mismatch
[Fact]
public void Stale_schema_snapshot_is_quarantined_and_state_rebuilt_from_full_log()
{
// Self-update scenario: the pre-update binary persisted a snapshot under an older
// DecisionState schema generation. The new binary must NOT trust it (Newtonsoft
// would silently default reshaped fields) — the snapshot is a cache, so it gets
// quarantined and the state is rebuilt from the authoritative SignalLog.
using var rig = new Rig();
Directory.CreateDirectory(rig.StateDir);
var sigs = new[]
{
MakeSignal(0, DecisionSignalKind.SessionStarted),
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
};
var seedLog = new SignalLogWriter(Path.Combine(rig.StateDir, "signal-log.jsonl"));
foreach (var s in sigs) seedLog.Append(s);
// Snapshot = state after the first two signals, stamped with a FOREIGN schema
// version. It parses cleanly (valid checksum via Save), so only the version
// check can reject it.
var engine = new DecisionEngine();
var midState = ReducerReplay.Replay(
engine, DecisionState.CreateInitial("S1", "T1"), new[] { sigs[0], sigs[1] });
var staleBuilder = midState.ToBuilder();
staleBuilder.SchemaVersion = "v0-test-stale";
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json"))
.Save(staleBuilder.Build());
var expected = ReducerReplay.Replay(
engine, DecisionState.CreateInitial("S1", "T1"), sigs);
var sut = rig.Build();
sut.Start();
// Full-log rebuild (not a tail replay): startup quarantine flagged, snapshot moved
// to a quarantine bucket whose reason names the schema mismatch, log untouched.
Assert.True(sut.WasStartupQuarantine);
Assert.Equal(expected.StepIndex, sut.CurrentState.StepIndex);
Assert.Equal(expected.LastAppliedSignalOrdinal, sut.CurrentState.LastAppliedSignalOrdinal);
Assert.Equal(DecisionState.CurrentSchemaVersion, sut.CurrentState.SchemaVersion);
Assert.False(File.Exists(Path.Combine(rig.StateDir, "snapshot.json")));
var reasons = Directory.GetFiles(rig.StateDir, "reason.txt", SearchOption.AllDirectories)
.Select(File.ReadAllText).ToList();
Assert.Contains(reasons, r => r.Contains("schema-mismatch:v0-test-stale"));
var signalLogPath = Path.Combine(rig.StateDir, "signal-log.jsonl");
Assert.True(File.Exists(signalLogPath));
Assert.True(new FileInfo(signalLogPath).Length > 0); // log NOT quarantined
sut.Stop();
}
[Fact]
public void Stale_schema_snapshot_with_unreadable_log_escalates_to_total_loss()
{
// Worst case: untrustworthy cache AND a log that is unreadable from the first
// line. Nothing remains to rebuild from — same total-loss escalation as the
// corrupt-snapshot branch, seeding a fresh Initial state.
using var rig = new Rig();
Directory.CreateDirectory(rig.StateDir);
var staleBuilder = DecisionState.CreateInitial("S1", "T1").ToBuilder();
staleBuilder.SchemaVersion = "v0-test-stale";
staleBuilder.StepIndex = 4;
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json"))
.Save(staleBuilder.Build());
File.WriteAllText(
Path.Combine(rig.StateDir, "signal-log.jsonl"),
"not-json-at-all\n",
Encoding.UTF8);
var sut = rig.Build();
sut.Start();
Assert.True(sut.WasStartupQuarantine);
Assert.Equal(0, sut.CurrentState.StepIndex); // fresh Initial seed
Assert.Equal(DecisionState.CurrentSchemaVersion, sut.CurrentState.SchemaVersion);
sut.Stop();
}
[Fact]
public void Journal_ahead_of_replayed_state_triggers_phantom_truncate()
{
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
/// </summary>
public sealed class TenantIdWaitResolutionTests
{
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
private static AgentConfiguration Build(BootstrapConfigFile? bootstrapConfig, params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// <summary>
/// Upload interval in seconds
/// </summary>
public int UploadIntervalSeconds { get; set; } = 30;
public int UploadIntervalSeconds { get; set; } = Shared.Constants.DefaultUploadIntervalSeconds;
/// <summary>
/// Maximum batch size for events
@@ -322,7 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
return new AgentConfigResponse
{
ConfigVersion = 0,
UploadIntervalSeconds = 30,
UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds,
SelfDestructOnComplete = true,
KeepLogFile = false,
EnableGeoLocation = true,
@@ -283,6 +283,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// and rebuild from the full SignalLog. If the log itself is head-corrupt
// (file non-empty but ReadAll yields zero signals) we escalate to a total-
// loss quarantine of the log segments and start fresh.
// a2) Snapshot parsed but carries a foreign DecisionState.SchemaVersion (written
// by a different binary generation, e.g. across a self-update) → same
// treatment as (a): the cache is untrustworthy, rebuild from the full log.
// b) Snapshot loaded cleanly → replay any SignalLog tail past the snapshot's
// LastAppliedSignalOrdinal so a pre-crash Journal/Snapshot lag is closed.
// c) No snapshot file — two sub-cases (Codex follow-up post-#50 #A):
@@ -365,6 +368,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
wasStartupQuarantine = true;
}
else if (loadedState != null
&& !string.Equals(loadedState.SchemaVersion, DecisionState.CurrentSchemaVersion, StringComparison.Ordinal))
{
// Branch (a2) — Snapshot parsed, but it was written by a DIFFERENT DecisionState
// schema generation (e.g. the pre-self-update binary). Trusting it would let
// Newtonsoft silently default/drop any reshaped fields — the mid-session-amnesia
// class this check exists to prevent. The snapshot is only a cache: quarantine it
// and rebuild from the authoritative SignalLog. Signals carry their own
// KindSchemaVersion and replaying them through the CURRENT reducer is the defined
// upgrade path. (Snapshots that PREDATE the SchemaVersion field deserialize with
// CurrentSchemaVersion via the ctor fallback and are not detectable here — the
// guard protects every generation from v4 on.)
logger.Warning(
$"EnrollmentOrchestrator: snapshot schema '{loadedState.SchemaVersion}' does not match " +
$"current '{DecisionState.CurrentSchemaVersion}' — quarantining snapshot and rebuilding " +
"state from the full SignalLog.");
snapshot.Quarantine($"schema-mismatch:{loadedState.SchemaVersion}->{DecisionState.CurrentSchemaVersion}");
var loggedSignals = signalLog.ReadAll();
var logFileInfo = new FileInfo(signalLogPath);
var logIsHeadCorrupt =
logFileInfo.Exists && logFileInfo.Length > 0 && loggedSignals.Count == 0;
if (logIsHeadCorrupt)
{
// Same escalation as branch (a): a cache we must not trust plus a log we
// cannot read leaves nothing to rebuild from — total-loss quarantine.
logger.Warning(
"EnrollmentOrchestrator: SignalLog unreadable from the first line — escalating to total-loss quarantine.");
SegmentQuarantine.QuarantineAll(
stateDirectory, "log-head-corrupt-after-schema-mismatch", () => clock.UtcNow);
signalLog = new SignalLogWriter(signalLogPath);
journal = new JournalWriter(journalPath, () => clock.UtcNow);
eventSequence = new EventSequencePersistence(eventSequencePath);
branchTag = "a2-schema-mismatch-total-loss";
}
else
{
signalsToReplay = loggedSignals;
branchTag = "a2-schema-mismatch-full-log-replay";
}
wasStartupQuarantine = true;
}
else if (loadedState != null)
{
// Branch (b) — Snapshot loaded. Catch up any SignalLog tail past the snapshot.
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// or hour-long gaps that hide live UI from operators).
private const int MinUploadIntervalSeconds = 5;
private const int MaxUploadIntervalSeconds = 300;
private const int DefaultUploadIntervalSeconds = 30;
private const int DefaultUploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds;
private const int MinUploadBatchSize = 1;
private const int MaxUploadBatchSize = 500;
private const int DefaultUploadBatchSize = 100;
@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.DecisionCore.Serialization;
using AutopilotMonitor.DecisionCore.Signals;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Enforces the fixture convention documented on <see cref="DecisionSignalKind"/>:
/// "New kinds or version bumps require a replay fixture in
/// <c>tests/fixtures/signal-kinds/{kind}-v{n}.json</c> — missing fixture = merge block."
/// The comment alone enforced nothing; this test does. Kinds without a fixture today are
/// carried in an explicit debt set that is only allowed to shrink.
/// </summary>
public sealed class SignalKindFixtureCoverageTests
{
// DEBT: backfill — new kinds must NOT be added here. Every entry predates this test;
// when a fixture is added for one of these kinds, the test forces its removal.
private static readonly HashSet<DecisionSignalKind> KnownMissingFixtures = new HashSet<DecisionSignalKind>
{
DecisionSignalKind.EspPhaseChanged,
DecisionSignalKind.EspExiting,
DecisionSignalKind.EspResumed,
DecisionSignalKind.EspTerminalFailure,
DecisionSignalKind.DesktopArrived,
DecisionSignalKind.HelloResolved,
DecisionSignalKind.ImeUserSessionCompleted,
DecisionSignalKind.DeviceSetupProvisioningComplete,
DecisionSignalKind.WhiteGloveShellCoreSuccess,
DecisionSignalKind.WhiteGloveSealingPatternDetected,
DecisionSignalKind.AadUserJoinedLate,
DecisionSignalKind.DeviceInfoCollected,
DecisionSignalKind.AutopilotProfileRead,
DecisionSignalKind.EspConfigDetected,
DecisionSignalKind.HelloPolicyDetected,
DecisionSignalKind.ClassifierVerdictIssued,
DecisionSignalKind.SessionStarted,
DecisionSignalKind.SessionAborted,
};
/// <summary>
/// Kinds that exist in the enum but are never posted as their own signal: they are
/// dispatched over the <see cref="DecisionSignalKind.DeadlineFired"/> rail (the reducer's
/// DeadlineFiredV1 switch routes on the <c>deadline</c> payload). Their fixture is named
/// after the kind, but its declared <c>Kind</c> is <c>DeadlineFired</c> — e.g.
/// <c>realmjoin-timeout-v1.json</c> carries <c>Payload.deadline = "realmjoin_timeout"</c>.
/// </summary>
private static readonly HashSet<DecisionSignalKind> DeadlineDispatchedKinds = new HashSet<DecisionSignalKind>
{
DecisionSignalKind.RealmJoinTimeout,
};
/// <summary>Locate <c>tests/fixtures/signal-kinds</c> by walking up to the repo root (AutopilotMonitor.sln).</summary>
private static string FixtureRoot()
{
var dir = AppContext.BaseDirectory;
for (int i = 0; i < 12; i++)
{
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
{
return Path.Combine(dir, "tests", "fixtures", "signal-kinds");
}
var parent = Directory.GetParent(dir)?.FullName;
if (parent == null || parent == dir) break;
dir = parent;
}
throw new DirectoryNotFoundException(
"Could not locate repo root (AutopilotMonitor.sln) walking up from " + AppContext.BaseDirectory);
}
private static IReadOnlyList<string> FixtureFiles()
{
var root = FixtureRoot();
Assert.True(Directory.Exists(root), $"Fixture directory not found: {root}");
var files = Directory.GetFiles(root, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToArray();
Assert.NotEmpty(files);
return files;
}
/// <summary>Signal JSONL lines of one fixture ('#' comments and blank lines stripped).</summary>
private static IReadOnlyList<string> SignalLines(string path)
{
var lines = File.ReadAllLines(path)
.Select(l => l.Trim())
.Where(l => l.Length > 0 && l[0] != '#')
.ToArray();
Assert.True(lines.Length > 0, $"Fixture contains no signal lines: {path}");
return lines;
}
/// <summary>
/// Strictly parse the raw <c>Kind</c> of a fixture line. Deliberately NOT via
/// <see cref="SignalSerializer"/> alone: its unknown-kind fallback maps typos to
/// <see cref="DecisionSignalKind.SessionStarted"/>, which would silently mis-count coverage.
/// </summary>
private static DecisionSignalKind StrictKind(string line, string path)
{
var raw = JObject.Parse(line).Value<string>("Kind");
Assert.False(string.IsNullOrEmpty(raw), $"Fixture line has no Kind: {path}");
Assert.True(
Enum.TryParse<DecisionSignalKind>(raw, ignoreCase: false, out var kind)
&& Enum.IsDefined(typeof(DecisionSignalKind), kind),
$"Fixture {Path.GetFileName(path)} declares unknown Kind '{raw}'.");
return kind;
}
/// <summary>Dash-insensitive lowercase — "realmjoin-detected-v1" and "RealmJoinDetected" both normalize consistently.</summary>
private static string Normalize(string value) => value.Replace("-", string.Empty).ToLowerInvariant();
/// <summary>
/// Map a fixture file name to the kind it is named after: the longest enum member whose
/// normalized name prefixes the normalized file name, followed by <c>v{n}[-variant]</c>
/// (dash placement per the existing corpus, e.g. <c>realmjoin-detected-v1.json</c> /
/// <c>admin-preemption-detected-v1-failed.json</c>).
/// </summary>
private static DecisionSignalKind KindFromFileName(string fileNameWithoutExtension)
{
var normalized = Normalize(fileNameWithoutExtension);
var match = ((DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind)))
.Where(k =>
{
var prefix = Normalize(k.ToString());
return normalized.StartsWith(prefix, StringComparison.Ordinal)
&& Regex.IsMatch(normalized.Substring(prefix.Length), "^v[0-9]+");
})
.OrderByDescending(k => k.ToString().Length)
.Cast<DecisionSignalKind?>()
.FirstOrDefault();
Assert.True(
match.HasValue,
$"Fixture file name '{fileNameWithoutExtension}.json' does not follow " +
"'{kind}-v{n}[-variant].json' for any DecisionSignalKind.");
return match!.Value;
}
/// <summary>
/// Load every fixture through the REAL deserialization path and return the set of kinds
/// the corpus covers. Also enforces per-file consistency: the declared Kind must match
/// the file name (or be the DeadlineFired dispatch shape for deadline-dispatched kinds).
/// </summary>
private static ISet<DecisionSignalKind> LoadAndVerifyFixtures()
{
var covered = new HashSet<DecisionSignalKind>();
foreach (var file in FixtureFiles())
{
var fileName = Path.GetFileNameWithoutExtension(file);
var namedKind = KindFromFileName(fileName);
foreach (var line in SignalLines(file))
{
var declaredKind = StrictKind(line, file);
// Real deserialization path — the same call FixtureLoader/ReplayHarness use.
var signal = SignalSerializer.Deserialize(line);
Assert.Equal(declaredKind, signal.Kind);
Assert.True(signal.KindSchemaVersion >= 1, $"KindSchemaVersion must be >= 1 in {fileName}.");
if (declaredKind == namedKind)
{
covered.Add(declaredKind);
}
else
{
// Only the documented dispatch exception is allowed: a fixture named after
// a deadline-dispatched kind carries a DeadlineFired signal with the
// deadline name in its payload.
string? deadlineName = null;
signal.Payload?.TryGetValue("deadline", out deadlineName);
Assert.True(
DeadlineDispatchedKinds.Contains(namedKind)
&& declaredKind == DecisionSignalKind.DeadlineFired
&& !string.IsNullOrEmpty(deadlineName),
$"Fixture '{fileName}.json' is named after {namedKind} but declares Kind={declaredKind}. " +
"Either the file is misnamed, or a new deadline-dispatched kind must be " +
"registered in DeadlineDispatchedKinds (with a payload.deadline entry).");
covered.Add(namedKind);
covered.Add(declaredKind); // it IS also a genuine DeadlineFired fixture
}
}
}
return covered;
}
[Fact]
public void Every_fixture_deserializes_through_the_real_signal_path_and_matches_its_filename()
{
LoadAndVerifyFixtures();
}
[Fact]
public void Every_kind_is_either_fixtured_or_registered_as_known_debt()
{
var fixtured = LoadAndVerifyFixtures();
var allKinds = (DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind));
var unfixturedAndUntracked = allKinds
.Where(k => !fixtured.Contains(k) && !KnownMissingFixtures.Contains(k))
.ToArray();
Assert.True(
unfixturedAndUntracked.Length == 0,
"DecisionSignalKind values without a replay fixture in tests/fixtures/signal-kinds/: " +
$"[{string.Join(", ", unfixturedAndUntracked)}]. The convention on DecisionSignalKind is " +
"'new kind => fixture' — add a {kind}-v{n}.json fixture (do NOT extend KnownMissingFixtures).");
// Debt only shrinks: once a fixture exists, the kind must leave the debt set.
var fixturedButStillListedAsDebt = KnownMissingFixtures.Where(fixtured.Contains).ToArray();
Assert.True(
fixturedButStillListedAsDebt.Length == 0,
"Kinds now have fixtures but are still listed in KnownMissingFixtures — remove them: " +
$"[{string.Join(", ", fixturedButStillListedAsDebt)}].");
}
}
}
@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AutopilotMonitor.DecisionCore.Serialization;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests.State
{
/// <summary>
/// Drift tripwire for the 4-site sync required when adding a field to
/// <see cref="DecisionState"/>: ctor parameter + <see cref="DecisionStateBuilder"/>
/// copy-ctor line + builder property + <c>Build()</c> argument. A forgotten copy-ctor
/// line silently resets the fact on every reducer step; a forgotten <c>Build()</c>
/// argument resets it on the first copy-with-changes. These tests are fully
/// reflection-driven so a new field fails them until all four sites are wired
/// (and until a value factory for any brand-new property type is added below).
/// </summary>
public sealed class DecisionStateBuilderCompletenessTests
{
private static readonly DateTime BaseUtc = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
private static PropertyInfo[] StateProperties() =>
typeof(DecisionState)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToArray();
private static PropertyInfo[] BuilderProperties() =>
typeof(DecisionStateBuilder)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.SetMethod != null && p.SetMethod.IsPublic)
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToArray();
/// <summary>
/// Produce a distinct, non-default value for a builder property. <paramref name="seed"/>
/// is folded into every value so no two properties of the same type ever share an
/// instance — a swapped <c>Build()</c> argument (e.g. espFinalExitUtc passed into
/// desktopArrivedUtc) is therefore detected, not masked. A property type without a
/// factory entry fails loudly: extend the factory when a new field type appears.
/// </summary>
private static object CreateDistinctValue(Type type, int seed)
{
long ordinal = 100 + seed;
if (type == typeof(string)) return "test-value-" + seed;
if (type == typeof(int)) return 7 + seed;
if (type == typeof(long)) return 7L + seed;
if (type == typeof(DateTime?)) return BaseUtc.AddMinutes(seed);
if (type == typeof(SessionStage)) return SessionStage.AwaitingHello;
if (type == typeof(SessionOutcome?)) return SessionOutcome.EnrollmentComplete;
if (type == typeof(SignalFact<DateTime>)) return new SignalFact<DateTime>(BaseUtc.AddMinutes(seed), ordinal);
if (type == typeof(SignalFact<string>)) return new SignalFact<string>("test-value-" + seed, ordinal);
if (type == typeof(SignalFact<bool>)) return new SignalFact<bool>(true, ordinal);
if (type == typeof(SignalFact<EnrollmentPhase>)) return new SignalFact<EnrollmentPhase>(EnrollmentPhase.AccountSetup, ordinal);
if (type == typeof(List<ActiveDeadline>))
{
return new List<ActiveDeadline>
{
new ActiveDeadline(
name: "deadline-" + seed,
dueAtUtc: BaseUtc.AddMinutes(seed),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string> { ["k"] = "v" + seed }),
};
}
// Immutable fact aggregates — built through their public APIs so the instances are
// reference-distinct from their Empty defaults AND fully serializable (a dropped
// copy-ctor line or Build() argument falls back to Empty → reference inequality).
if (type == typeof(AppInstallFacts)) return AppInstallFacts.Empty.WithCompleted("Installed").WithFailed("app-" + seed);
if (type == typeof(EnrollmentScenarioProfile))
{
return EnrollmentScenarioProfile.Empty.With(
mode: EnrollmentMode.WhiteGlove,
confidence: ProfileConfidence.High,
evidenceOrdinal: ordinal,
reason: "test-value-" + seed);
}
if (type == typeof(EnrollmentScenarioObservations)) return EnrollmentScenarioObservations.Empty.WithSkipUserEsp(true, ordinal);
if (type == typeof(ClassifierOutcomes))
{
return ClassifierOutcomes.Empty.WithWhiteGloveSealing(new Hypothesis(
level: HypothesisLevel.Strong,
reason: "test-value-" + seed,
score: 50,
lastUpdatedUtc: BaseUtc.AddMinutes(seed),
lastClassifierVerdictId: "verdict-" + seed));
}
if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal);
throw new Xunit.Sdk.XunitException(
$"No value factory for builder property type {type}. A new DecisionState field " +
"introduced a new type — add a distinct-value factory entry so the completeness " +
"tests can populate it.");
}
/// <summary>Populate every settable builder property with a distinct non-default value.</summary>
private static Dictionary<string, object> PopulateBuilder(DecisionStateBuilder builder)
{
var assigned = new Dictionary<string, object>(StringComparer.Ordinal);
int seed = 0;
foreach (var prop in BuilderProperties())
{
var value = CreateDistinctValue(prop.PropertyType, seed++);
prop.SetValue(builder, value);
assigned[prop.Name] = value;
}
return assigned;
}
private static void AssertMatches(string propertyName, object expected, object? actual)
{
// Deadlines: Build() copies the List into an array and the copy-ctor re-wraps it —
// the container is a new instance by design, but the elements must be the SAME refs.
if (expected is List<ActiveDeadline> expectedDeadlines)
{
var actualDeadlines = Assert.IsAssignableFrom<IEnumerable<ActiveDeadline>>(actual).ToList();
Assert.Equal(expectedDeadlines.Count, actualDeadlines.Count);
for (int i = 0; i < expectedDeadlines.Count; i++)
{
Assert.Same(expectedDeadlines[i], actualDeadlines[i]);
}
return;
}
// Value types (enums, int, long, DateTime?) and strings compare by value; every
// reference-typed fact must be the identical instance (a re-created equal-looking
// value would still hide a dropped pass-through).
if (expected is string || expected.GetType().IsValueType)
{
Assert.True(
expected.Equals(actual),
$"{propertyName}: expected {expected}, got {actual ?? "(null)"}.");
}
else
{
Assert.Same(expected, actual);
}
}
// ============================================================= (a) name-set parity
[Fact]
public void Builder_property_set_matches_DecisionState_property_set()
{
var stateNames = StateProperties().Select(p => p.Name).ToArray();
var builderNames = BuilderProperties().Select(p => p.Name).ToArray();
var missingOnBuilder = stateNames.Except(builderNames, StringComparer.Ordinal).ToArray();
var missingOnState = builderNames.Except(stateNames, StringComparer.Ordinal).ToArray();
Assert.True(
missingOnBuilder.Length == 0 && missingOnState.Length == 0,
"DecisionState and DecisionStateBuilder property sets drifted apart. " +
$"State-only: [{string.Join(", ", missingOnBuilder)}]; " +
$"Builder-only: [{string.Join(", ", missingOnState)}]. " +
"Adding a DecisionState field requires: ctor parameter, builder copy-ctor line, " +
"builder property, and Build() argument.");
}
// ============================================================= (b) Build() completeness
[Fact]
public void Build_carries_every_builder_property_onto_the_state()
{
var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
var assigned = PopulateBuilder(builder);
var state = builder.Build();
foreach (var stateProp in StateProperties())
{
Assert.True(
assigned.TryGetValue(stateProp.Name, out var expected),
$"DecisionState.{stateProp.Name} has no builder counterpart — see the name-set parity test.");
AssertMatches($"DecisionState.{stateProp.Name}", expected!, stateProp.GetValue(state));
}
}
// ============================================================= (c) copy-ctor completeness
[Fact]
public void ToBuilder_copies_every_property_back_from_the_state()
{
var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
var assigned = PopulateBuilder(builder);
var state = builder.Build();
var roundtrippedBuilder = state.ToBuilder();
foreach (var builderProp in BuilderProperties())
{
var expected = assigned[builderProp.Name];
AssertMatches($"DecisionStateBuilder.{builderProp.Name}", expected, builderProp.GetValue(roundtrippedBuilder));
}
}
// ============================================================= (d) serializer roundtrip
[Fact]
public void StateSerializer_roundtrip_of_fully_populated_state_is_lossless()
{
// Catches Newtonsoft ctor-binding mismatches: deserialization binds JSON properties
// onto the single public ctor by (case-insensitive) parameter name, so a renamed
// ctor parameter silently drops the field back to its default. Serializing the
// deserialized state must therefore reproduce the exact same JSON.
var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
PopulateBuilder(builder);
var state = builder.Build();
var json = StateSerializer.Serialize(state);
var roundtrippedJson = StateSerializer.Serialize(StateSerializer.Deserialize(json));
Assert.Equal(json, roundtrippedJson);
}
}
}
@@ -418,6 +418,21 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Same(seed, profile);
}
[Fact]
public void ImeUserSessionCompleted_devicePreparationMedium_isNoOp()
{
// WDP guard: the IME user channel runs on Device Preparation too, so this signal
// cannot discriminate Classic from WDP. The registry-deterministic v2 seed sits at
// only Medium confidence — without the explicit guard the High-confidence-only bail
// would let this signal silently reclassify every WDP session as Classic.
var seed = DevicePreparationSeed();
var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
Assert.Same(seed, profile);
}
// ================================================================== ApplySelfDeployingDeadlineConfirmed
// Plan v9 — ApplyDeviceSetupProvisioningComplete was removed (signal-time Mode promotion
// caused false-positive SelfDeploying terminations on Classic flows, session 88a53223).
@@ -483,6 +498,19 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Same(seed, profile);
}
[Fact]
public void SelfDeployingDeadlineConfirmed_devicePreparationMedium_isNoOp()
{
// WDP guard mirror: the DeviceOnlyEspDetection deadline is ESP-derived and should be
// unreachable on WDP, but a misfire must not displace the registry v2 fact.
var seed = DevicePreparationSeed();
var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed(seed, signal);
Assert.Same(seed, profile);
}
[Fact]
public void WhiteGloveSealingConfirmed_setsWhiteGloveHigh_andTechnicianSide()
{
@@ -497,6 +525,20 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Equal("classifier_whiteglove_sealing_confirmed", profile.Reason);
}
[Fact]
public void WhiteGloveSealingConfirmed_devicePreparationMedium_isNoOp()
{
// WhiteGlove pre-provisioning is an ESP technician flow that cannot occur on WDP —
// a Confirmed sealing verdict against a v2-profiled session is a classifier misfire.
// This updater previously had NO guard at all, so it could overwrite any mode.
var seed = DevicePreparationSeed();
var signal = MakeSignal(DecisionSignalKind.ClassifierVerdictIssued, ordinal: 15, payload: null);
var profile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(seed, signal);
Assert.Same(seed, profile);
}
// ================================================================== AadUserJoinedLate
[Fact]
@@ -608,6 +650,17 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
}
/// <summary>
/// Profile exactly as seeded by ApplyEnrollmentFactsObserved for enrollmentType "v2" —
/// the state the WDP downgrade-guards protect.
/// </summary>
private static EnrollmentScenarioProfile DevicePreparationSeed() =>
EnrollmentScenarioProfile.Empty.With(
mode: EnrollmentMode.DevicePreparation,
confidence: ProfileConfidence.Medium,
reason: "enrollment_facts_observed:v2",
evidenceOrdinal: 0);
private static DecisionSignal MakeSignal(
DecisionSignalKind kind,
long ordinal,
@@ -869,9 +869,26 @@ namespace AutopilotMonitor.SummaryDialog
private static string FormatPhaseName(string phase)
{
// Convert "DeviceSetup" -> "Device Setup", "AccountSetup" -> "Account Setup"
if (string.IsNullOrEmpty(phase)) return phase;
// Canonical display names, mirroring EnrollmentEvent.GetPhaseName in Shared.
// This dialog is deliberately dependency-free (standalone EXE), so the mapping is
// restated here instead of referencing Shared — the camel-case splitter below is
// only the fallback for unknown tokens and cannot produce "Apps (Device)".
switch (phase)
{
case "Start": return "Start";
case "DevicePreparation": return "Device Preparation";
case "DeviceSetup": return "Device Setup";
case "AppsDevice": return "Apps (Device)";
case "AccountSetup": return "Account Setup";
case "AppsUser": return "Apps (User)";
case "FinalizingSetup": return "Finalizing Setup";
case "Complete": return "Complete";
case "Failed": return "Failed";
}
// Fallback: "SomeNewPhase" -> "Some New Phase"
var result = new System.Text.StringBuilder();
for (int i = 0; i < phase.Length; i++)
{
@@ -21,6 +21,8 @@ public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
[InlineData("enrollment_complete")]
[InlineData("enrollment_failed")]
[InlineData("vulnerability_correlated")]
[InlineData("whiteglove_sealed")]
[InlineData("interim_trigger")]
public void Envelope_round_trips_through_newtonsoft_for_all_reasons(string reason)
{
var original = new AnalyzeOnEnrollmentEndEnvelope
@@ -43,6 +45,33 @@ public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
Assert.Equal(EnqueuedAt, restored.EnqueuedAt.ToUniversalTime());
}
[Fact]
public void Envelope_trigger_event_types_round_trip_and_default_to_null()
{
// interim_trigger envelopes carry the batch's matched trigger types; every other
// reason leaves the field null so legacy consumers see an unchanged shape.
var original = new AnalyzeOnEnrollmentEndEnvelope
{
TenantId = TenantId,
SessionId = SessionId,
Reason = "interim_trigger",
TriggerEventTypes = new List<string> { "hybrid_login_pending", "custom_probe_signal" },
EnqueuedAt = EnqueuedAt,
};
var restored = JsonConvert.DeserializeObject<AnalyzeOnEnrollmentEndEnvelope>(
JsonConvert.SerializeObject(original));
Assert.NotNull(restored);
Assert.Equal(new List<string> { "hybrid_login_pending", "custom_probe_signal" }, restored!.TriggerEventTypes);
// A legacy envelope without the field deserializes to null, not empty.
var legacy = JsonConvert.DeserializeObject<AnalyzeOnEnrollmentEndEnvelope>(
$"{{\"EnvelopeVersion\":\"1\",\"TenantId\":\"{TenantId}\",\"SessionId\":\"{SessionId}\",\"Reason\":\"enrollment_complete\"}}");
Assert.NotNull(legacy);
Assert.Null(legacy!.TriggerEventTypes);
}
[Fact]
public void Envelope_default_envelope_version_is_one()
{
@@ -0,0 +1,380 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Apps;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the app-version duration regression radar (<see cref="AppVersionRegressionRadar"/>):
/// detection gates (≥10 measured installs on both sides, median lift ≥2×, ≥300s absolute),
/// first-seen version ordering (never string sort), the measured-population exclusions,
/// re-arm semantics, the tracker keyspace round-trip, the wording contract, and the
/// versionBreakdown duration fields on the analytics wire shape.
/// </summary>
public class AppVersionRegressionRadarTests
{
private const string TenantA = "00000000-0000-0000-0000-0000000000a1";
private const string App = "Contoso VPN";
private static readonly DateTime Now = new(2026, 8, 13, 12, 0, 0, DateTimeKind.Utc);
private static int _sessionCounter;
private static AppInstallSummary Install(
string version,
int durationSeconds,
DateTime? startedAt = null,
string appName = App,
string status = "Succeeded",
string terminalState = "Installed",
bool collision = false)
=> new()
{
TenantId = TenantA,
SessionId = $"s-{++_sessionCounter}",
AppName = appName,
AppVersion = version,
Status = status,
TerminalState = terminalState,
DurationSeconds = durationSeconds,
StartedAt = startedAt ?? Now.AddDays(-1),
AppIdCollision = collision,
};
/// <summary>n measured installs of one version, all with the same duration, spread over one day.</summary>
private static List<AppInstallSummary> Batch(string version, int count, int durationSeconds, DateTime firstSeen)
=> Enumerable.Range(0, count)
.Select(i => Install(version, durationSeconds, firstSeen.AddMinutes(i)))
.ToList();
// ── detection gates ─────────────────────────────────────────────────────
[Theory]
// currentCount, previousCount, previousMedian, currentMedian, expectFire
[InlineData(10, 10, 300, 900, true)] // lift 3×, +600s — fires
[InlineData(10, 10, 300, 600, true)] // exactly 2× AND exactly +300s — boundary fires
[InlineData(9, 10, 300, 900, false)] // current side below sample floor
[InlineData(10, 9, 300, 900, false)] // previous side below sample floor
[InlineData(10, 10, 200, 499, false)] // lift 2.5× but only +299s — absolute floor blocks
[InlineData(10, 10, 1000, 1900, false)] // +900s but lift 1.9× — lift gate blocks
public void Evaluate_Gates(int currentCount, int previousCount, int previousMedian, int currentMedian, bool expectFire)
{
var rows = Batch("1.0", previousCount, previousMedian, Now.AddDays(-20));
rows.AddRange(Batch("2.0", currentCount, currentMedian, Now.AddDays(-5)));
var findings = AppVersionRegressionRadar.Evaluate(rows);
if (!expectFire)
{
Assert.Empty(findings);
return;
}
var finding = Assert.Single(findings);
Assert.Equal(App, finding.AppName);
Assert.Equal("2.0", finding.CurrentVersion);
Assert.Equal("1.0", finding.PreviousVersion);
Assert.Equal(currentMedian, finding.CurrentMedianSeconds);
Assert.Equal(previousMedian, finding.PreviousMedianSeconds);
Assert.Equal(currentCount, finding.CurrentMeasuredCount);
Assert.Equal(previousCount, finding.PreviousMeasuredCount);
Assert.Equal(Math.Round((double)currentMedian / previousMedian, 1), finding.Lift);
}
[Fact]
public void Evaluate_MedianIsNearestRank_HandComputed()
{
// Even n: nearest-rank p50 takes the LOWER middle value (rank = ceil(0.5*4)-1 = 1).
var rows = new List<AppInstallSummary>();
foreach (var d in new[] { 100, 200, 300, 400 }) // median 200
rows.AddRange(Batch("1.0", 3, d, Now.AddDays(-20)));
foreach (var d in new[] { 500, 600, 700, 800 }) // median 600
rows.AddRange(Batch("2.0", 3, d, Now.AddDays(-5)));
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
Assert.Equal(600, finding.CurrentMedianSeconds);
Assert.Equal(200, finding.PreviousMedianSeconds);
Assert.Equal(3.0, finding.Lift);
}
// ── version ordering ────────────────────────────────────────────────────
[Fact]
public void Evaluate_VersionOrder_ByFirstSeen_NeverStringSort()
{
// Ordinally "9.1" > "2024.10", but "2024.10" is the NEWER version by first-seen.
// A string sort would flip current/previous and compare in the wrong direction.
var rows = Batch("9.1", 10, 300, Now.AddDays(-20));
rows.AddRange(Batch("2024.10", 10, 900, Now.AddDays(-5)));
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
Assert.Equal("2024.10", finding.CurrentVersion);
Assert.Equal("9.1", finding.PreviousVersion);
}
[Fact]
public void Evaluate_MultipleVersionsInFlight_PreviousIsLatestFirstSeenBeforeCurrent()
{
var rows = Batch("1.0", 10, 300, Now.AddDays(-30)); // oldest
rows.AddRange(Batch("1.1", 10, 320, Now.AddDays(-18))); // the true predecessor
rows.AddRange(Batch("2.0", 10, 960, Now.AddDays(-4))); // newest, regressed
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
Assert.Equal("2.0", finding.CurrentVersion);
Assert.Equal("1.1", finding.PreviousVersion); // not 1.0
Assert.Equal(320, finding.PreviousMedianSeconds);
}
[Fact]
public void Evaluate_SingleVersion_NeverFires()
{
var rows = Batch("1.0", 25, 900, Now.AddDays(-5));
Assert.Empty(AppVersionRegressionRadar.Evaluate(rows));
}
// ── measured-population exclusions ──────────────────────────────────────
[Fact]
public void Evaluate_ExcludedRows_DoNotCountTowardSamplesOrMedians()
{
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)));
// None of these may move the samples or medians:
rows.Add(Install("2.0", 0, Now.AddDays(-2))); // unmeasured (start unobserved)
rows.Add(Install("2.0", 21601, Now.AddDays(-2))); // over the 6h plausibility cap
rows.Add(Install("2.0", 50, Now.AddDays(-2), terminalState: "Skipped")); // skip — not an attempt
rows.Add(Install("2.0", 50, Now.AddDays(-2), terminalState: "Postponed")); // skip — not an attempt
rows.Add(Install("2.0", 50, Now.AddDays(-2), status: "Failed")); // failed — no duration signal
rows.Add(Install("2.0", 50, Now.AddDays(-2), collision: true)); // AppId collision — foreign outcomes
rows.Add(Install("", 50, Now.AddDays(-2))); // no version
rows.Add(Install("2.0", 50, Now.AddDays(-2), appName: "")); // no app name
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
Assert.Equal(10, finding.CurrentMeasuredCount);
Assert.Equal(900, finding.CurrentMedianSeconds);
}
[Fact]
public void Evaluate_DeterministicOrdering_LiftDesc_ThenAppNameOrdinal()
{
var rows = new List<AppInstallSummary>();
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.AppName = "App B"; return s; }));
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)).Select(s => { s.AppName = "App B"; return s; })); // lift 3.0
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.AppName = "App A"; return s; }));
rows.AddRange(Batch("2.0", 10, 1500, Now.AddDays(-5)).Select(s => { s.AppName = "App A"; return s; })); // lift 5.0
var findings = AppVersionRegressionRadar.Evaluate(rows);
Assert.Equal(2, findings.Count);
Assert.Equal("App A", findings[0].AppName); // higher lift first
Assert.Equal("App B", findings[1].AppName);
}
// ── re-arm ──────────────────────────────────────────────────────────────
private static AppVersionRegressionAlert Alert(
string currentVersion = "2.0", string previousVersion = "1.0")
=> new()
{
TenantId = TenantA,
AppName = App,
CurrentVersion = currentVersion,
PreviousVersion = previousVersion,
CurrentMedianSeconds = 900,
PreviousMedianSeconds = 300,
CurrentMeasuredCount = 10,
PreviousMeasuredCount = 10,
Lift = 3.0,
FirstNotifiedAt = Now.AddDays(-3),
LastEvaluatedAt = Now.AddDays(-1),
};
[Fact]
public void ShouldReArm_WhenCurrentVersionDrainedOutOfHorizon()
{
// Only the previous version still has measured installs — the alerted version is gone.
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
}
[Fact]
public void ShouldReArm_WhenCurrentVersionBelowSampleFloor()
{
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
rows.AddRange(Batch("2.0", 9, 900, Now.AddDays(-5)));
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
}
[Fact]
public void ShouldReArm_WhenMedianFellBackUnderReArmFactor()
{
// 400 < 1.5 × 300 — the regression recovered (e.g. CDN warmed up).
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
rows.AddRange(Batch("2.0", 10, 400, Now.AddDays(-5)));
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
}
[Fact]
public void ShouldNotReArm_WhileMedianStaysElevated()
{
// 500 ≥ 1.5 × 300 — still elevated (even though below the 2× fire gate).
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
rows.AddRange(Batch("2.0", 10, 500, Now.AddDays(-5)));
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
}
[Fact]
public void ShouldNotReArm_WhenPreviousVersionDrained_NoComparisonBasis()
{
// The predecessor left the horizon; the episode is kept until the current
// version itself drains (fires-stopped-style re-arm only).
var rows = Batch("2.0", 10, 900, Now.AddDays(-5));
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
}
// ── tracker keyspace (round-trip + key shape) ───────────────────────────
[Fact]
public void AppVersionRegressionRowKey_PrefixedCaseFoldedAndSanitized()
{
Assert.Equal("appversionregression|contoso app_2|1.0_beta_",
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(
" Contoso App/2 ", " 1.0#Beta? "));
}
[Fact]
public void AppVersionRegressionRowKey_NoPrefixCollisionWithOtherKeyspaces()
{
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(App, "1.0");
Assert.StartsWith("appversionregression|", key, StringComparison.Ordinal);
// The prefix range [prefix, "appversionregression}") must not swallow the sibling
// keyspaces ("ruleregression|", "tpmpss|", "{mfr}|{model}").
Assert.True(string.CompareOrdinal("ruleregression|", "appversionregression}") > 0);
Assert.True(string.CompareOrdinal("tpmpss|", "appversionregression}") > 0);
}
[Fact]
public void AppVersionRegressionEntity_RoundTripsAllFields()
{
var alert = new AppVersionRegressionAlert
{
TenantId = TenantA,
AppName = App,
CurrentVersion = "2.4.0",
PreviousVersion = "2.3.9",
CurrentMedianSeconds = 1740,
PreviousMedianSeconds = 660,
CurrentMeasuredCount = 12,
PreviousMeasuredCount = 40,
Lift = 2.6,
FirstNotifiedAt = Now,
LastEvaluatedAt = Now.AddHours(2),
};
var entity = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionEntity(TenantA, alert);
Assert.Equal(TenantA.ToLowerInvariant(), entity.PartitionKey);
Assert.Equal("appversionregression|contoso vpn|2.4.0", entity.RowKey);
var mapped = TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity);
Assert.Equal(TenantA, mapped.TenantId);
Assert.Equal(App, mapped.AppName);
Assert.Equal("2.4.0", mapped.CurrentVersion);
Assert.Equal("2.3.9", mapped.PreviousVersion);
Assert.Equal(1740, mapped.CurrentMedianSeconds);
Assert.Equal(660, mapped.PreviousMedianSeconds);
Assert.Equal(12, mapped.CurrentMeasuredCount);
Assert.Equal(40, mapped.PreviousMeasuredCount);
Assert.Equal(2.6, mapped.Lift);
Assert.Equal(Now, mapped.FirstNotifiedAt);
Assert.Equal(Now.AddHours(2), mapped.LastEvaluatedAt);
}
// ── wording contract ────────────────────────────────────────────────────
[Fact]
public void RegressionMessage_CarriesTheFullNumbers_MinutesOneDecimal()
{
var finding = new AppVersionDurationRegressionFinding
{
TenantId = TenantA,
AppName = App,
CurrentVersion = "2.4.0",
PreviousVersion = "2.3.9",
CurrentMedianSeconds = 1740,
PreviousMedianSeconds = 660,
CurrentMeasuredCount = 12,
PreviousMeasuredCount = 40,
Lift = 2.6,
};
Assert.Equal(
"Median install duration rose from 11 to 29 min after version 2.4.0 " +
"(12 measured installs vs 40 on version 2.3.9) — lift 2.6x over the last 35 days.",
MaintenanceService.BuildAppVersionRegressionMessage(finding));
}
// ── analytics wire shape (versionBreakdown duration fields) ─────────────
[Fact]
public async Task Analytics_VersionBreakdown_CarriesMeasuredDurationStats()
{
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((SessionSummary?)null);
var rows = new List<AppInstallSummary>
{
Install("1.0", 100), Install("1.0", 200), Install("1.0", 300), Install("1.0", 400),
Install("1.0", 0), // unmeasured — excluded from stats
Install("1.0", 50, terminalState: "Skipped"), // skip — excluded
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
};
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
rows, sessionRepo.Object, App, days: 30));
var version = root.GetProperty("versionBreakdown")[0];
Assert.Equal("1.0", version.GetProperty("appVersion").GetString());
Assert.Equal(7, version.GetProperty("installs").GetInt32());
Assert.Equal(4, version.GetProperty("measuredInstalls").GetInt32());
// Nearest-rank over [100, 200, 300, 400]: p50 = 200 (lower middle), p95 = 400.
Assert.Equal(200, version.GetProperty("medianDurationSeconds").GetInt32());
Assert.Equal(400, version.GetProperty("p95DurationSeconds").GetInt32());
}
[Fact]
public async Task Analytics_VersionRegressions_SerializedCamelCase_AndPresentWhenEmptyData()
{
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((SessionSummary?)null);
var alert = Alert();
// The Functions host serializes camelCase on the wire; mirror that here so the
// asserted property names match what the web client actually parses.
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
// Empty-data early return still surfaces the block (episodes can outlive window data).
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions);
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions);
var regression = root.GetProperty("versionRegressions")[0];
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
Assert.Equal(900, regression.GetProperty("currentMedianSeconds").GetInt32());
}
}
@@ -59,7 +59,7 @@ public class AuthenticationMiddlewareTests
[InlineData("GET", "/api/auth/me")] // AuthenticatedUser
[InlineData("GET", "/api/auth/is-global-admin")] // AuthenticatedUser
[InlineData("POST", "/api/realtime/negotiate")] // AuthenticatedUser
[InlineData("GET", "/api/progress/sessions")] // AuthenticatedUser
[InlineData("GET", "/api/progress/sessions/lookup")] // AuthenticatedUserWithRole
[InlineData("GET", "/api/sessions")] // MemberRead
[InlineData("GET", "/api/audit/logs")] // MemberRead
[InlineData("PUT", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
@@ -8,6 +8,11 @@
</PropertyGroup>
<ItemGroup>
<!-- Same compile context as the product project: with the shared framework referenced,
NuGet prunes framework-provided transitive packages (e.g. the vulnerable
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
@@ -238,6 +238,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/bootstrap/sessions")]
[InlineData("GET", "/api/diagnostics/download-url")]
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
[InlineData("GET", "/api/progress/sessions/lookup")]
public void QueryParam_NoQueryTenant_DefaultsToJwt(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: null);
@@ -254,6 +255,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/bootstrap/sessions")]
[InlineData("GET", "/api/diagnostics/download-url")]
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
[InlineData("GET", "/api/progress/sessions/lookup")]
public void QueryParam_SameTenant_IsAllowed(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: TenantA);
@@ -274,6 +276,7 @@ public class CrossTenantAccessTests
[InlineData("DELETE", "/api/bootstrap/sessions/CODE123")]
[InlineData("GET", "/api/diagnostics/download-url")]
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
[InlineData("GET", "/api/progress/sessions/lookup")]
public void QueryParam_CrossTenant_NonGA_IsBlocked(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: TenantB);
@@ -291,6 +294,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/bootstrap/sessions")]
[InlineData("GET", "/api/diagnostics/download-url")]
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
[InlineData("GET", "/api/progress/sessions/lookup")]
public void QueryParam_CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: true, queryTenantId: TenantB);
@@ -316,6 +320,7 @@ public class CrossTenantAccessTests
[InlineData("DELETE", "bootstrap/sessions/{code}")]
[InlineData("GET", "diagnostics/download-url")]
[InlineData("GET", "progress/sessions/{sessionId}/events")]
[InlineData("GET", "progress/sessions/lookup")]
public void QueryParamRoutes_HaveCorrectScoping(string httpMethod, string routeTemplate)
{
var entry = EndpointAccessPolicyCatalog.Entries
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
Assert.Equal(11, c["signalsByKind"]);
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
// + tombstone = 20 steps; no inventory steps when no contributions row.
Assert.Equal(20, manifest.Steps.Count);
// 20 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
// SessionTenantLookup P6.1) + tombstone = 21 steps; no inventory steps when no contributions row.
Assert.Equal(21, manifest.Steps.Count);
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
}
@@ -105,7 +105,8 @@ public class DeletionManifestBuilderTests
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
.WithSignalsByKind(1, propValue: "signalkind_marker")
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
.WithSessionAnnotations(propValue: "annotation_marker"));
.WithSessionAnnotations(propValue: "annotation_marker")
.WithSessionTenantLookup(propValue: "lookup_marker"));
var manifest = await NewBuilder(reader).BuildAsync(
TenantId, SessionId, "admin_delete",
@@ -148,9 +149,9 @@ public class DeletionManifestBuilderTests
new DeletionRetentionContext { TenantRetentionDays = 90 });
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps).
Assert.Equal(19, nonTombstoneSteps.Count);
// Without a contributions row, neither inventory step is emitted → exactly 20 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
Assert.Equal(20, nonTombstoneSteps.Count);
foreach (var step in nonTombstoneSteps)
{
Assert.Equal(0, step.RowCount);
@@ -179,8 +180,8 @@ public class DeletionManifestBuilderTests
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
Assert.Equal(22, manifest.Steps.Count);
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
Assert.Equal(23, manifest.Steps.Count);
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
@@ -292,6 +293,11 @@ public class DeletionManifestBuilderTests
Assert.NotEqual(SessionId, tombstone.Rows[0].Rk); // SessionsIndex first
Assert.Contains(SessionId, tombstone.Rows[0].Rk, StringComparison.Ordinal); // RK still references the session
Assert.Equal(SessionId, tombstone.Rows[1].Rk); // Sessions row second
// FINAL rows carry their source table explicitly (DeletionStep.Table is null on this
// step); delete/restore route by it instead of the legacy RowKey-shape heuristic.
Assert.Equal(Constants.TableNames.SessionsIndex, tombstone.Rows[0].Table);
Assert.Equal(Constants.TableNames.Sessions, tombstone.Rows[1].Table);
}
[Fact]
@@ -646,6 +652,7 @@ public class DeletionManifestBuilderTests
private TableEntity? _eventSessionIndex;
private TableEntity? _sessionTimeBreakdown;
private TableEntity? _sessionInventoryContributions;
private TableEntity? _sessionTenantLookup;
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
@@ -801,6 +808,17 @@ public class DeletionManifestBuilderTests
return this;
}
public ReaderSeed WithSessionTenantLookup(string propValue = "lookup_marker")
{
_sessionTenantLookup = new TableEntity(SessionId, "tenant")
{
["TenantId"] = TenantId,
["Marker"] = propValue,
};
_sessionTenantLookup.ETag = new ETag("0xLOOKUP");
return this;
}
public ISessionDeletionInventoryReader Build()
{
var reader = new Mock<ISessionDeletionInventoryReader>();
@@ -833,6 +851,8 @@ public class DeletionManifestBuilderTests
.ReturnsAsync(_sessionTimeBreakdown);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionInventoryContributions);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTenantLookup, SessionId, "tenant", It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionTenantLookup);
foreach (var (lane, row) in _sessionAnnotations)
{
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
@@ -0,0 +1,61 @@
using System.Text.Json;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Deletion;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the FINAL-step table resolution: an explicit <see cref="DeletionRowDump.Table"/> wins
/// unconditionally; only legacy manifests (written before the field existed) fall back to the
/// RowKey-shape heuristic. The adversarial rows deliberately contradict the heuristic so a
/// regression to Contains('_') routing flips the assertion.
/// </summary>
public class DeletionTombstoneTablesTests
{
[Fact]
public void Explicit_table_wins_over_contradicting_rowkey_shape()
{
// RK contains '_' (heuristic would say SessionsIndex) but Table says Sessions — and vice versa.
var sessionsRow = new DeletionRowDump { Pk = "t", Rk = "id_with_underscores", Table = Constants.TableNames.Sessions };
var indexRow = new DeletionRowDump { Pk = "t", Rk = "plainkey", Table = Constants.TableNames.SessionsIndex };
Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(sessionsRow));
Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(indexRow));
}
[Fact]
public void Legacy_rows_without_table_fall_back_to_rowkey_shape()
{
// Pre-field manifests: Sessions RK is the bare session GUID, index RK is composed.
var legacySessions = new DeletionRowDump { Pk = "t", Rk = "22222222-2222-2222-2222-222222222222" };
var legacyIndex = new DeletionRowDump { Pk = "t", Rk = "6299999999999999999_22222222-2222-2222-2222-222222222222" };
Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(legacySessions));
Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(legacyIndex));
}
[Fact]
public void Table_field_round_trips_through_manifest_json()
{
var dump = new DeletionRowDump { Pk = "t", Rk = "rk", Table = Constants.TableNames.SessionsIndex };
var json = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
Assert.Contains("\"table\":", json);
var roundTripped = JsonSerializer.Deserialize<DeletionRowDump>(json, DeletionManifestJson.SerializerOptions);
Assert.Equal(Constants.TableNames.SessionsIndex, roundTripped!.Table);
}
[Fact]
public void Legacy_manifest_json_without_table_deserializes_to_null_and_is_omitted_on_write()
{
// WhenWritingNull keeps rows of table-targeted steps (Table always null there) byte-
// identical to the pre-field format — SchemaHash of those steps is unaffected.
var legacyJson = "{\"pk\":\"t\",\"rk\":\"rk\",\"props\":{}}";
var dump = JsonSerializer.Deserialize<DeletionRowDump>(legacyJson, DeletionManifestJson.SerializerOptions);
Assert.Null(dump!.Table);
var rewritten = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
Assert.DoesNotContain("table", rewritten);
}
}
@@ -9,11 +9,13 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the API-latency aggregation of
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/> (2026-07-28): both the
/// per-location and the global figure are REQUEST-WEIGHTED averages —
/// Σ(sessionAvg·requests)/Σ(requests) — not averages of session averages, so a long session
/// with many uploads counts proportionally. Sessions without latency data (agents predating
/// <c>AvgApiLatencyMs</c>) drop out of the weighting entirely.
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/>: the avg figures are
/// REQUEST-WEIGHTED averages — Σ(sessionAvg·requests)/Σ(requests) — not averages of session
/// averages, so a long session with many uploads counts proportionally. The median figures
/// (upper median over per-session averages, duration convention) are the robust display
/// statistic and the baseline for <c>ApiLatencyVsGlobalPct</c>: one corrupt session average
/// (a request spanning a sleep/hibernate gap) must not drag a whole location (2026-08-15).
/// Sessions without latency data (agents predating <c>AvgApiLatencyMs</c>) drop out entirely.
/// </summary>
public class GeographicMetricsApiLatencyTests
{
@@ -47,9 +49,37 @@ public class GeographicMetricsApiLatencyTests
var loc = Assert.Single(result.Locations);
// (1000·300 + 500·100) / 400 = 875 — an unweighted mean would say 750.
Assert.Equal(875d, loc.AvgApiLatencyMs);
// Upper median of [500, 1000].
Assert.Equal(1000d, loc.MedianApiLatencyMs);
Assert.Equal(2, loc.ApiLatencySessionCount);
}
[Fact]
public void MedianLatency_IsRobustAgainstCorruptSessionAverage()
{
// Real-world shape (Amersfoort 2026-08): one session whose average was inflated to
// ~23 minutes per request by a sleep-spanning sample, among ordinary ~150-200 ms peers.
var sessions = new List<SessionSummary>
{
S("NL", "Amersfoort", 150, 100),
S("NL", "Amersfoort", 170, 80),
S("NL", "Amersfoort", 200, 120),
S("NL", "Amersfoort", 1_382_551.5, 115),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
// The weighted mean is wrecked by the corrupt session…
Assert.True(loc.AvgApiLatencyMs > 100_000);
// …the median is not: upper median of [150, 170, 200, 1382551.5] = 200.
Assert.Equal(200d, loc.MedianApiLatencyMs);
Assert.Equal(200d, result.GlobalAverages.MedianApiLatencyMs);
// Median vs global median (same single location) → no deviation.
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
}
[Fact]
public void SessionsWithoutLatencyData_DropOutOfWeighting_ButStayInSessionCount()
{
@@ -67,6 +97,7 @@ public class GeographicMetricsApiLatencyTests
Assert.Equal(3, loc.SessionCount);
Assert.Equal(1, loc.ApiLatencySessionCount);
Assert.Equal(200d, loc.AvgApiLatencyMs);
Assert.Equal(200d, loc.MedianApiLatencyMs);
}
[Fact]
@@ -79,9 +110,11 @@ public class GeographicMetricsApiLatencyTests
var loc = Assert.Single(result.Locations);
Assert.Equal(0d, loc.AvgApiLatencyMs);
Assert.Equal(0d, loc.MedianApiLatencyMs);
Assert.Equal(0, loc.ApiLatencySessionCount);
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
Assert.Equal(0d, result.GlobalAverages.AvgApiLatencyMs);
Assert.Equal(0d, result.GlobalAverages.MedianApiLatencyMs);
}
[Fact]
@@ -98,11 +131,13 @@ public class GeographicMetricsApiLatencyTests
// Equal request weights → global = (200 + 800) / 2 = 500.
Assert.Equal(500d, result.GlobalAverages.AvgApiLatencyMs);
// Upper median of [200, 800].
Assert.Equal(800d, result.GlobalAverages.MedianApiLatencyMs);
var de = result.Locations.Single(l => l.Country == "DE");
var id = result.Locations.Single(l => l.Country == "ID");
Assert.Equal(-60d, de.ApiLatencyVsGlobalPct); // 200 vs 500
Assert.Equal(60d, id.ApiLatencyVsGlobalPct); // 800 vs 500
Assert.Equal(-75d, de.ApiLatencyVsGlobalPct); // median 200 vs global median 800
Assert.Equal(0d, id.ApiLatencyVsGlobalPct); // median 800 vs global median 800
}
[Fact]
@@ -119,6 +154,7 @@ public class GeographicMetricsApiLatencyTests
var fr = result.Locations.Single(l => l.Country == "FR");
Assert.Equal(0d, fr.AvgApiLatencyMs);
Assert.Equal(0d, fr.MedianApiLatencyMs);
Assert.Equal(0d, fr.ApiLatencyVsGlobalPct); // no data ≠ "0 ms fast"
}
}
@@ -640,6 +640,12 @@ public class TenantOffboardingHandlerDrainContractTests
return Task.FromResult(0);
}
public override Task<int> WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
{
WipeCallCount++;
return Task.FromResult(0);
}
public override Task<int> WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default)
{
WipeCallCount++;
@@ -293,6 +293,33 @@ public class TenantOffboardingHandlerOrderingTests
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
}
[Fact]
public async Task PostDrain_RowKeyWipe_CoversUsageMetrics()
{
// UsageMetrics rows are PK=date, RK=tenantId — the former exact-PK wipe matched 0
// rows, so per-tenant snapshots survived offboarding. The RowKey-anchored Variant D
// wipe is their only tenant-lifecycle deletion.
var harness = Harness.New();
await harness.Sut.HandleAsync(harness.Envelope());
Assert.Contains(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.RowKeyWipes);
Assert.DoesNotContain(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.ExactPartitionWipes);
}
[Fact]
public async Task PostDrain_ExactPartitionWipe_CoversTenantStatsAndScriptNameCache()
{
// PlatformStats carries per-tenant counter rows (PK=tenantId, RK="current") and
// ScriptNameCache is keyed PK=tenantId — both previously survived offboarding.
var harness = Harness.New();
await harness.Sut.HandleAsync(harness.Envelope());
Assert.Contains(Constants.TableNames.PlatformStats, harness.SafeWipeProbe.ExactPartitionWipes);
Assert.Contains(Constants.TableNames.ScriptNameCache, harness.SafeWipeProbe.ExactPartitionWipes);
}
// ── Harness (copied minimal — only what these tests need) ───────────────────
private sealed class Harness
@@ -442,6 +469,8 @@ public class TenantOffboardingHandlerOrderingTests
public List<string> PropertyOnlyWipes { get; } = new();
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
public List<string> ExactPartitionWipes { get; } = new();
/// <summary>Table names passed to the RowKey-anchored (Variant D) wipe — lets tests assert coverage.</summary>
public List<string> RowKeyWipes { get; } = new();
public CountingSafeWipeService() : base(
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
@@ -451,6 +480,7 @@ public class TenantOffboardingHandlerOrderingTests
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
public override Task<int> WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); }
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
}
@@ -55,6 +55,14 @@ public class OffboardingFiltersTests
Assert.Throws<ArgumentException>(() => OffboardingFilters.TenantIdProperty(input));
}
[Theory]
[InlineData("not-a-guid")]
[InlineData("")]
public void RowKeyEquals_ThrowsForNonGuid(string input)
{
Assert.Throws<ArgumentException>(() => OffboardingFilters.RowKeyEquals(input));
}
// ── Filter shapes ───────────────────────────────────────────────────────────
[Fact]
@@ -97,6 +105,17 @@ public class OffboardingFiltersTests
Assert.DoesNotContain("PartitionKey", filter);
}
[Fact]
public void RowKeyEquals_EmitsRowKeyOnlyFilter()
{
// Variant D anchors on the ROW key (UsageMetrics: PK=date, RK=tenantId) — the
// filter must not constrain PartitionKey, otherwise it degenerates back into the
// exact-PK wipe that matched 0 rows.
var filter = OffboardingFilters.RowKeyEquals(ValidTenant);
Assert.Equal($"RowKey eq '{ValidTenant}'", filter);
Assert.DoesNotContain("PartitionKey", filter);
}
// ── Injection neutralization (defense in depth even after EnsureValidGuid) ──
[Fact]
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet against NEW anonymous-object API responses: <c>req.OkAsync(new { ... })</c> ships a
/// shape no type checks, no manifest exports, and the web mirrors by hand — the drift class the
/// 2026-08-13 fragility audit found across 44 call sites. New/changed endpoints return typed
/// response DTOs (AutopilotMonitor.Shared.Models, picked up by SharedManifestParityTests);
/// the existing sites are frozen as a per-file baseline that may only SHRINK:
/// - a new anonymous OkAsync (new file, or count above baseline) fails,
/// - converting a site to a typed DTO fails too until its baseline entry is lowered/removed,
/// so the baseline always reflects reality and the debt is visible in the diff.
/// Typed object initializers (<c>OkAsync(new SomeResponse { ... })</c>) do not match.
/// </summary>
public class OkAsyncBaselineGuardTests
{
private static readonly Regex AnonymousOkAsync = new(@"OkAsync\(\s*new\s*\{", RegexOptions.Compiled);
/// <summary>
/// Frozen 2026-08-13 baseline (44 sites / 34 files), relative to the Functions project.
/// Only ever lower a count or delete an entry — never raise or add.
/// </summary>
private static readonly Dictionary<string, int> Baseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Admin/GetActiveUsersFunction.cs"] = 1,
["Functions/Admin/GetAuditLogsFunction.cs"] = 2,
["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2,
["Functions/Admin/GetOpsEventsFunction.cs"] = 2,
["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1,
["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 1,
["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 1,
["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 2,
["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 2,
["Functions/Graph/GetGraphPermissionsStatusFunction.cs"] = 1,
["Functions/Graph/GetScriptDisplayNamesFunction.cs"] = 3,
["Functions/Graph/RefreshGraphPermissionsFunction.cs"] = 1,
["Functions/Raw/QueryRawEventsFunction.cs"] = 2,
["Functions/Raw/QueryRawSessionsFunction.cs"] = 1,
["Functions/Reports/GetDeviceNotRegisteredFunction.cs"] = 1,
["Functions/Reports/GetHardwareRejectedFunction.cs"] = 1,
["Functions/Reports/GetSessionReportsFunction.cs"] = 2,
["Functions/Reports/GetTpmPssUnsupportedFunction.cs"] = 1,
["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 1,
["Functions/Sessions/GetAllSessionsFunction.cs"] = 1,
["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1,
["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 1,
["Functions/Sessions/GetSessionEventsFunction.cs"] = 2,
["Functions/Sessions/GetSessionFunction.cs"] = 1,
["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1,
["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1,
["Functions/Sessions/GetSessionStatsFunction.cs"] = 1,
["Functions/Sessions/GetSessionsFunction.cs"] = 1,
["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 1,
["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1,
["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1,
["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 1,
["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 1,
["Functions/Sessions/SearchSessionsFunction.cs"] = 1,
};
[Fact]
public void Anonymous_OkAsync_sites_never_exceed_the_frozen_baseline()
{
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
var actual = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) ||
relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
continue;
var count = AnonymousOkAsync.Matches(File.ReadAllText(file)).Count;
if (count > 0)
actual[relative] = count;
}
var violations = new List<string>();
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
if (!Baseline.TryGetValue(file, out var allowed))
violations.Add($"{file}: {count} NEW anonymous OkAsync site(s) — return a typed response DTO instead.");
else if (count > allowed)
violations.Add($"{file}: {count} anonymous OkAsync site(s), baseline allows {allowed} — return a typed response DTO for the new one(s).");
}
// Ratchet: a converted site must also lower its baseline entry, so the frozen debt
// list stays truthful (and its shrink is visible in the diff).
foreach (var (file, allowed) in Baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
actual.TryGetValue(file, out var count);
if (count < allowed)
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(OkAsyncBaselineGuardTests)} (ratchet down).");
}
Assert.True(violations.Count == 0,
"Anonymous OkAsync(new { ... }) baseline violated:\n " + string.Join("\n ", violations));
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -605,7 +605,7 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed);
}
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
// ── Transactional config patch (TenantAdminOrGA) / backups + revert (GlobalAdminOnly) ──
[Fact]
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
@@ -658,13 +658,12 @@ public class PolicyEnforcementMiddlewareTests
}
[Fact]
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
public async Task ConfigBackupAndRevertRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
{
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
// Backups + revert stay deliberately GA-only (operator restore surface) — even the
// tenant's OWN admin is denied. Only the PATCH line was widened in phase 2.
foreach (var (method, path) in new[]
{
("PATCH", $"/api/config/{TenantA}/fields"),
("GET", $"/api/config/{TenantA}/backups"),
("POST", $"/api/config/{TenantA}/revert"),
})
@@ -672,7 +671,7 @@ public class PolicyEnforcementMiddlewareTests
var ownAdmin = BuildHarness();
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin");
var reader = BuildHarness();
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
@@ -682,10 +681,49 @@ public class PolicyEnforcementMiddlewareTests
var delegated = BuildHarness();
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin");
}
}
[Fact]
public async Task ConfigFieldPatch_OwnTenantAdmin_IsAllowed()
{
// Phase 2: the web's per-section save PATCHes the caller's own config row. The
// GA-only field protection moved into the service's TenantAdmin caller tier
// (explicit 400 per field) — the route itself admits the own-tenant admin.
var h = BuildHarness();
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var result = await h.Middleware.DecideAsync(
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
Assert.True(result.Allowed);
}
[Fact]
public async Task ConfigFieldPatch_ForeignTenant_Reader_Delegated_AreForbidden()
{
// RouteParam scoping: a tenant admin cannot patch ANOTHER tenant's row; the read-only
// Global Reader and delegated (MSP) admins stay excluded entirely.
var foreignAdmin = BuildHarness();
foreignAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
var foreignResult = await foreignAdmin.Middleware.DecideAsync(
"PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
Assert.False(foreignResult.Allowed, "own-tenant admin must not patch a foreign tenant's config");
var reader = BuildHarness();
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var readerResult = await reader.Middleware.DecideAsync(
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "reader@vendor.example"));
Assert.False(readerResult.Allowed, "read-only Global Reader must not patch config fields");
var delegated = BuildHarness();
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
var delegatedResult = await delegated.Middleware.DecideAsync(
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "msp@partner.example"));
Assert.False(delegatedResult.Allowed, "delegated (MSP) admin must not patch config fields");
}
[Fact]
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
{
@@ -0,0 +1,158 @@
using AutopilotMonitor.Functions.Functions.Progress;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the Progress Portal's serial-knowledge authorization model: the policy-catalog
/// registration (no tenant-wide list route; lookup resolves the role flags), the role-based
/// lookup matching (<see cref="ProgressPortalFunction.FindBestMatch"/>), and the serial proof
/// both the events route and the SignalR session-group join share (<see cref="SerialKnowledgeProof"/>).
/// </summary>
public class ProgressPortalFunctionTests
{
private const string Serial = "PF3XKQ7";
private const string Device = "AP-LAB-042";
private static SessionSummary Session(
string serial = Serial, string device = Device, int ageMinutes = 0, string? sessionId = null)
=> new()
{
SessionId = sessionId ?? Guid.NewGuid().ToString(),
TenantId = "11111111-1111-1111-1111-111111111111",
SerialNumber = serial,
DeviceName = device,
StartedAt = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc).AddMinutes(-ageMinutes),
};
// ── Policy catalog ───────────────────────────────────────────────────────
[Fact]
public void TenantWide_list_route_is_gone_from_the_catalog()
{
// THE FINDING: GET progress/sessions shipped the tenant's latest 100 sessions to any
// authenticated (roleless) user, with the serial filter applied only in the browser.
// The route must stay unregistered — the catalog fail-closes unknown routes to 403.
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/sessions"));
}
[Fact]
public void Lookup_route_is_AuthenticatedUserWithRole_with_QueryParam_scoping()
{
// AuthenticatedUserWithRole, NOT MemberRead (the portal's audience is roleless end
// users — regression history c4dabeee) and NOT plain AuthenticatedUser (the function
// needs the resolved role flags to grant members substring search).
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/sessions/lookup");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.AuthenticatedUserWithRole, entry!.Policy);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
[Fact]
public void Events_route_stays_AuthenticatedUser_with_QueryParam_scoping()
{
// The serial proof is enforced in-function; the tier must remain reachable for
// roleless end users.
var entry = EndpointAccessPolicyCatalog.FindPolicy(
"GET", "progress/sessions/22222222-2222-2222-2222-222222222222/events");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.AuthenticatedUser, entry!.Policy);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
// ── FindBestMatch: roleless callers need the EXACT serial / device name ──
[Theory]
[InlineData(Serial)]
[InlineData("pf3xkq7")] // case-insensitive
[InlineData(" PF3XKQ7 ")] // trimmed
[InlineData(Device)]
[InlineData("ap-lab-042")]
public void Roleless_exact_serial_or_device_name_matches(string search)
{
var match = ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: false);
Assert.NotNull(match);
}
[Theory]
[InlineData("PF3")] // serial prefix
[InlineData("3XKQ")] // serial fragment
[InlineData("AP-LAB")] // device-name prefix
[InlineData("P")] // one-character fishing
public void Roleless_substring_does_NOT_match(string search)
{
// The exact serial IS the authorization proof. Substring matching would degrade it to
// "knows any one character" and let a roleless user fish through the tenant.
var match = ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: false);
Assert.Null(match);
}
[Theory]
[InlineData("PF3")]
[InlineData("AP-LAB")]
[InlineData("lab-042")]
public void Member_substring_still_matches(string search)
{
// Members/GA keep the portal's original fuzzy search — for them the same data is
// MemberRead at REST anyway (regression guard for the helpdesk workflow).
var match = ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: true);
Assert.NotNull(match);
}
[Fact]
public void Newest_matching_session_wins()
{
var older = Session(ageMinutes: 120, sessionId: "older");
var newest = Session(ageMinutes: 0, sessionId: "newest");
var unrelated = Session(serial: "OTHER01", device: "OTHER-DEV", ageMinutes: 1, sessionId: "unrelated");
var match = ProgressPortalFunction.FindBestMatch(
new[] { older, unrelated, newest }, Serial, allowSubstring: false);
Assert.Equal("newest", match!.SessionId);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("NO-SUCH-DEVICE")]
public void No_match_returns_null(string search)
{
Assert.Null(ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: false));
Assert.Null(ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: true));
}
// ── SerialKnowledgeProof: the shared events/SignalR-join comparison ──────
[Theory]
[InlineData(Serial, Serial)]
[InlineData(Serial, "pf3xkq7")] // case-insensitive
[InlineData(Serial, " PF3XKQ7 ")] // trimmed
[InlineData(" PF3XKQ7 ", Serial)] // stored value trimmed too
public void SerialProof_matches_exact_serial(string stored, string provided)
{
Assert.True(SerialKnowledgeProof.Matches(stored, provided));
}
[Theory]
[InlineData(Serial, "PF3XKQ")] // near-miss
[InlineData(Serial, "PF3")] // prefix is NOT proof
[InlineData(Serial, null)]
[InlineData(Serial, "")]
[InlineData(Serial, " ")]
[InlineData(null, Serial)] // session without a serial is unreachable (fail-closed)
[InlineData("", Serial)]
[InlineData(null, null)]
public void SerialProof_rejects_everything_else(string? stored, string? provided)
{
Assert.False(SerialKnowledgeProof.Matches(stored, provided));
}
}
@@ -0,0 +1,82 @@
using System;
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the single inverted-tick RowKey encoder every table-key builder now routes through.
/// The encodings are PERSISTED — a change here reorders live tables against their existing
/// rows, so the exact widths and boundary literals are asserted, not just round-trips.
/// </summary>
public class RowKeyCodecTests
{
[Fact]
public void InvertedTicks_boundary_literals_are_pinned()
{
// rev(MaxValue) = 0 and rev(MinValue) = MaxValue.Ticks — exact strings, no derivation.
Assert.Equal("0000000000000000000", RowKeyCodec.InvertedTicks(DateTime.MaxValue));
Assert.Equal("3155378975999999999", RowKeyCodec.InvertedTicks(DateTime.MinValue));
}
[Fact]
public void InvertedTicks_is_fixed_width_19_digits()
{
foreach (var utc in SampleInstants())
{
var key = RowKeyCodec.InvertedTicks(utc);
Assert.Equal(19, key.Length);
Assert.All(key, c => Assert.InRange(c, '0', '9'));
}
}
[Fact]
public void InvertedTicksD20_is_the_same_value_one_zero_wider()
{
// UserActivity legacy width: identical rev value, padded to 20 digits. Existing D20
// rows pin this — a D19 key would sort after every D20 key in that table.
foreach (var utc in SampleInstants())
{
var d20 = RowKeyCodec.InvertedTicksD20(utc);
Assert.Equal(20, d20.Length);
Assert.Equal("0" + RowKeyCodec.InvertedTicks(utc), d20);
}
}
[Fact]
public void Later_instants_sort_lexicographically_first()
{
var earlier = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var later = earlier.AddSeconds(1);
Assert.True(string.CompareOrdinal(RowKeyCodec.InvertedTicks(later), RowKeyCodec.InvertedTicks(earlier)) < 0);
}
[Fact]
public void Decode_round_trips_encode_exactly()
{
foreach (var utc in SampleInstants())
{
Assert.True(RowKeyCodec.TryDecodeInvertedTicks(RowKeyCodec.InvertedTicks(utc), out var decoded));
Assert.Equal(utc.Ticks, decoded.Ticks);
Assert.Equal(DateTimeKind.Utc, decoded.Kind);
}
}
[Theory]
[InlineData("")] // empty
[InlineData("123456789012345678x")] // non-digit
[InlineData("!155378975999999999")] // prefix leaked into the digit run
[InlineData("9999999999999999999")] // rev > MaxValue.Ticks → negative tick value
public void Decode_rejects_foreign_input(string digits)
{
Assert.False(RowKeyCodec.TryDecodeInvertedTicks(digits, out _));
}
private static DateTime[] SampleInstants() => new[]
{
new DateTime(2026, 8, 13, 18, 30, 12, DateTimeKind.Utc),
new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc).AddTicks(7),
DateTime.MinValue,
DateTime.MaxValue,
};
}
@@ -108,4 +108,35 @@ public class RuleEngineDataPathResolverTests
Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
}
[Fact]
public void TryFlatLookup_ListValue_FlattensToJoinedString()
{
// Regression: app_tracking_summary.pendingNames is a List<object> after DataJson
// deserialization. ToString() on it yields the .NET type name, which broke both
// contains-matching and {{pendingNames}} interpolation in ANALYZE-APP-015 findings.
var data = new Dictionary<string, object>
{
["pendingNames"] = new List<object> { "Contoso VPN", "Fabrikam Agent" },
};
var ok = RuleEngine.TryFlatLookup(data, "pendingNames", out var value);
Assert.True(ok);
Assert.Equal("Contoso VPN, Fabrikam Agent", value);
}
[Fact]
public void ResolveDotPath_ListLeaf_FlattensToJoinedString()
{
var data = new Dictionary<string, object>
{
["summary"] = new Dictionary<string, object>
{
["names"] = new List<object> { "a", "b" },
},
};
Assert.Equal("a, b", RuleEngine.ResolveDotPath(data, "summary.names"));
}
}
@@ -38,7 +38,7 @@ public class RuleEngineDetectionFailureTests
// and there's a co-located esp_failure_settle_started with errorCode=0x87d1041c.
// The rule must fire and the explanation must interpolate to the Office app.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
Assert.Equal("1.1.0", rule.Version);
Assert.Equal("1.2.0", rule.Version);
Assert.True(rule.Enabled);
Assert.Equal("critical", rule.Severity);
@@ -0,0 +1,512 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// evaluateOn interim-trigger semantics (docs/rules/analyze-rule-triggers.md):
/// trigger grammar + matching helpers, the engine's interim rule filter, the
/// RuleResult update/finalize/resolve lifecycle replacing the permanent dedupe
/// freeze, KO suppression on interim runs, and the ingest-side trigger registry.
/// </summary>
public class RuleEngineEvaluateOnTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
private const string ProbeEventType = "custom_probe_signal";
// ── Trigger grammar helpers ─────────────────────────────────────────────
[Fact]
public void EffectiveTriggers_absent_or_empty_defaults_to_enrollment_end()
{
Assert.Equal(new[] { "enrollment_end" }, AnalyzeRuleTriggers.EffectiveTriggers(new AnalyzeRule()));
Assert.Equal(new[] { "enrollment_end" },
AnalyzeRuleTriggers.EffectiveTriggers(new AnalyzeRule { EvaluateOn = new List<string>() }));
var rule = new AnalyzeRule();
Assert.True(AnalyzeRuleTriggers.RunsAtEnrollmentEnd(rule));
Assert.False(AnalyzeRuleTriggers.RunsAtWhitegloveSealed(rule));
Assert.Empty(AnalyzeRuleTriggers.OnEventTypes(rule));
}
[Fact]
public void OnEventTypes_parses_prefix_and_matching_is_case_insensitive()
{
var rule = new AnalyzeRule
{
EvaluateOn = new List<string> { "enrollment_end", "on_event:Hybrid_Login_Pending", "whiteglove_sealed" },
};
Assert.Equal(new[] { "hybrid_login_pending" }, AnalyzeRuleTriggers.OnEventTypes(rule));
Assert.True(AnalyzeRuleTriggers.RunsAtWhitegloveSealed(rule));
Assert.True(AnalyzeRuleTriggers.MatchesOnEvent(rule, new[] { "HYBRID_LOGIN_PENDING" }));
Assert.False(AnalyzeRuleTriggers.MatchesOnEvent(rule, new[] { "desktop_arrived" }));
Assert.False(AnalyzeRuleTriggers.MatchesOnEvent(rule, Array.Empty<string>()));
}
[Theory]
[InlineData("enrollment_end", true)]
[InlineData("whiteglove_sealed", true)]
[InlineData("on_event:hybrid_login_pending", true)]
[InlineData("on_event:x1_2", true)]
[InlineData("on_event:", false)]
[InlineData("on_event:Not-Snake", false)]
[InlineData("phase_exit:AccountSetup", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsValidTrigger_enforces_the_grammar(string? trigger, bool expected)
{
Assert.Equal(expected, AnalyzeRuleTriggers.IsValidTrigger(trigger));
}
// ── Engine: interim rule filter ─────────────────────────────────────────
[Fact]
public async Task Interim_run_evaluates_only_rules_matching_the_trigger()
{
var matching = ProbeRule("ANALYZE-TEST-9101", evaluateOn: new List<string> { "enrollment_end", $"on_event:{ProbeEventType}" });
var terminalOnly = ProbeRule("ANALYZE-TEST-9102", evaluateOn: null);
var fixture = CreateFixture(new List<AnalyzeRule> { matching, terminalOnly }, ProbeEvents());
var outcome = await fixture.Engine.AnalyzeSessionAsync(
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-TEST-9101", result.RuleId);
Assert.True(result.IsInterim);
Assert.NotNull(result.FirstDetectedAt);
Assert.NotNull(result.LastEvaluatedAt);
Assert.Equal("ANALYZE-TEST-9101", Assert.Single(outcome.EvaluatedRules).RuleId);
}
[Fact]
public async Task Interim_run_with_no_matching_rules_skips_the_event_read()
{
var terminalOnly = ProbeRule("ANALYZE-TEST-9103", evaluateOn: null);
var fixture = CreateFixture(new List<AnalyzeRule> { terminalOnly }, ProbeEvents());
var outcome = await fixture.Engine.AnalyzeSessionAsync(
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
Assert.Empty(outcome.Results);
Assert.Empty(outcome.EvaluatedRules);
fixture.SessionRepo.Verify(
s => s.GetSessionEventsStrictAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()),
Times.Never);
}
[Fact]
public async Task Whiteglove_sealed_run_matches_only_whiteglove_rules()
{
var wgRule = ProbeRule("ANALYZE-TEST-9104", evaluateOn: new List<string> { "enrollment_end", "whiteglove_sealed" });
var onEventRule = ProbeRule("ANALYZE-TEST-9105", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
var fixture = CreateFixture(new List<AnalyzeRule> { wgRule, onEventRule }, ProbeEvents());
var outcome = await fixture.Engine.AnalyzeSessionAsync(
TenantId, SessionId, AnalyzeRunContext.WhitegloveSealed());
Assert.Equal("ANALYZE-TEST-9104", Assert.Single(outcome.Results).RuleId);
}
// ── Engine: KO suppression ──────────────────────────────────────────────
[Fact]
public async Task Interim_run_suppresses_the_KO_escalation()
{
var koRule = ProbeRule("ANALYZE-TEST-9106", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
koRule.MarkSessionAsFailedDefault = true;
var fixture = CreateFixture(new List<AnalyzeRule> { koRule }, ProbeEvents());
var outcome = await fixture.Engine.AnalyzeSessionAsync(
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
Assert.Single(outcome.Results);
// TryMarkSessionFailedFromRuleAsync starts with GetSessionAsync — never reached on interim.
fixture.SessionRepo.Verify(s => s.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Terminal_run_still_applies_the_KO_escalation()
{
var koRule = ProbeRule("ANALYZE-TEST-9107", evaluateOn: null);
koRule.MarkSessionAsFailedDefault = true;
var fixture = CreateFixture(new List<AnalyzeRule> { koRule }, ProbeEvents());
await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
fixture.SessionRepo.Verify(s => s.GetSessionAsync(TenantId, SessionId), Times.Once);
}
// ── Engine: result lifecycle (update semantics) ─────────────────────────
[Fact]
public async Task Terminal_run_skips_settled_final_rows_but_finalizes_interim_rows()
{
var settled = ProbeRule("ANALYZE-TEST-9108", evaluateOn: null);
var interim = ProbeRule("ANALYZE-TEST-9109", evaluateOn: new List<string> { "enrollment_end", $"on_event:{ProbeEventType}" });
var firstDetected = new DateTime(2026, 8, 13, 10, 0, 0, DateTimeKind.Utc);
var notified = new DateTime(2026, 8, 13, 10, 5, 0, DateTimeKind.Utc);
var existing = new List<RuleResult>
{
StoredResult("ANALYZE-TEST-9108", isInterim: false),
StoredResult("ANALYZE-TEST-9109", isInterim: true, firstDetectedAt: firstDetected, notifiedAt: notified, resultId: "stable-result-id"),
};
var fixture = CreateFixture(new List<AnalyzeRule> { settled, interim }, ProbeEvents(), existing);
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
// Settled final row: classic dedupe — not re-evaluated, not in EvaluatedRules.
Assert.DoesNotContain(outcome.EvaluatedRules, r => r.RuleId == "ANALYZE-TEST-9108");
// Interim row: finalized with preserved identity + notification marker.
var finalized = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-TEST-9109", finalized.RuleId);
Assert.False(finalized.IsInterim);
Assert.Null(finalized.ResolvedAt);
Assert.Equal(firstDetected, finalized.FirstDetectedAt);
Assert.Equal(firstDetected, finalized.DetectedAt);
Assert.Equal(notified, finalized.NotifiedAt);
Assert.Equal("stable-result-id", finalized.ResultId);
}
[Fact]
public async Task Terminal_run_resolves_interim_rows_that_no_longer_fire()
{
// The rule requires an event type the session does NOT contain → no fire.
var interim = ProbeRule("ANALYZE-TEST-9110",
evaluateOn: new List<string> { "enrollment_end", "on_event:some_other_signal" },
conditionEventType: "some_other_signal");
var existing = new List<RuleResult> { StoredResult("ANALYZE-TEST-9110", isInterim: true) };
var fixture = CreateFixture(new List<AnalyzeRule> { interim }, ProbeEvents(), existing);
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
Assert.Empty(outcome.Results);
var resolved = Assert.Single(outcome.ResolvedResults);
Assert.Equal("ANALYZE-TEST-9110", resolved.RuleId);
Assert.NotNull(resolved.ResolvedAt);
Assert.False(resolved.IsInterim); // terminal settles the row
}
[Fact]
public async Task Terminal_run_settles_a_row_an_interim_pass_already_resolved()
{
// Interim resolve keeps IsInterim=true (a later interim run may re-fire it);
// the terminal pass must settle it to a final resolved row.
var interim = ProbeRule("ANALYZE-TEST-9118",
evaluateOn: new List<string> { "enrollment_end", "on_event:some_other_signal" },
conditionEventType: "some_other_signal");
var alreadyResolved = StoredResult("ANALYZE-TEST-9118", isInterim: true);
alreadyResolved.ResolvedAt = new DateTime(2026, 8, 13, 13, 0, 0, DateTimeKind.Utc);
var fixture = CreateFixture(new List<AnalyzeRule> { interim }, ProbeEvents(),
new List<RuleResult> { alreadyResolved });
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
var settled = Assert.Single(outcome.ResolvedResults);
Assert.False(settled.IsInterim);
Assert.Equal(alreadyResolved.ResolvedAt, settled.ResolvedAt); // original resolve time kept
}
[Fact]
public async Task Interim_refresh_keeps_interim_flag_and_preserves_notification_marker()
{
var rule = ProbeRule("ANALYZE-TEST-9111", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
var notified = new DateTime(2026, 8, 13, 11, 0, 0, DateTimeKind.Utc);
var existing = new List<RuleResult>
{
StoredResult("ANALYZE-TEST-9111", isInterim: true, notifiedAt: notified),
};
var fixture = CreateFixture(new List<AnalyzeRule> { rule }, ProbeEvents(), existing);
var outcome = await fixture.Engine.AnalyzeSessionAsync(
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
var refreshed = Assert.Single(outcome.Results);
Assert.True(refreshed.IsInterim);
Assert.Equal(notified, refreshed.NotifiedAt);
}
[Fact]
public async Task Interim_run_never_reopens_a_settled_final_row()
{
var rule = ProbeRule("ANALYZE-TEST-9112", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
var existing = new List<RuleResult> { StoredResult("ANALYZE-TEST-9112", isInterim: false) };
var fixture = CreateFixture(new List<AnalyzeRule> { rule }, ProbeEvents(), existing);
var outcome = await fixture.Engine.AnalyzeSessionAsync(
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
Assert.Empty(outcome.Results);
Assert.Empty(outcome.ResolvedResults);
}
[Fact]
public async Task Reanalyze_preserves_notification_marker_and_resolves_stale_rows()
{
// fires: probe rule matches; stale: rule over an absent event type with a stored FINAL row.
var firing = ProbeRule("ANALYZE-TEST-9113", evaluateOn: null);
var stale = ProbeRule("ANALYZE-TEST-9114", evaluateOn: null, conditionEventType: "some_other_signal");
var notified = new DateTime(2026, 8, 13, 12, 0, 0, DateTimeKind.Utc);
var existing = new List<RuleResult>
{
StoredResult("ANALYZE-TEST-9113", isInterim: false, notifiedAt: notified),
StoredResult("ANALYZE-TEST-9114", isInterim: false),
};
var fixture = CreateFixture(new List<AnalyzeRule> { firing, stale }, ProbeEvents(), existing);
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, reanalyze: true);
var refired = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-TEST-9113", refired.RuleId);
Assert.Equal(notified, refired.NotifiedAt);
var resolved = Assert.Single(outcome.ResolvedResults);
Assert.Equal("ANALYZE-TEST-9114", resolved.RuleId);
Assert.NotNull(resolved.ResolvedAt);
}
// ── Ingest trigger registry ─────────────────────────────────────────────
[Fact]
public async Task Registry_aggregates_on_event_types_and_whiteglove_flag()
{
var rules = new List<AnalyzeRule>
{
ProbeRule("ANALYZE-TEST-9115", evaluateOn: new List<string> { "enrollment_end", $"on_event:{ProbeEventType}" }),
ProbeRule("ANALYZE-TEST-9116", evaluateOn: new List<string> { "whiteglove_sealed" }),
ProbeRule("ANALYZE-TEST-9117", evaluateOn: null),
};
var fixture = CreateFixture(rules, ProbeEvents());
var registry = new AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry(
fixture.RuleService, NullLogger<AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry>.Instance);
var triggers = await registry.GetAsync(TenantId);
Assert.Contains(ProbeEventType, triggers.OnEventTypes);
Assert.Single(triggers.OnEventTypes);
Assert.True(triggers.HasWhitegloveSealedRules);
}
[Fact]
public async Task Registry_is_fail_soft_on_rule_load_errors()
{
var repo = new Mock<IRuleRepository>();
repo.Setup(r => r.GetAnalyzeRulesAsync(It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("storage down"));
var ruleService = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var registry = new AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry(
ruleService, NullLogger<AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry>.Instance);
var triggers = await registry.GetAsync(TenantId);
Assert.Empty(triggers.OnEventTypes);
Assert.False(triggers.HasWhitegloveSealedRules);
}
// ── Built-in catalog wiring ─────────────────────────────────────────────
[Fact]
public void BuiltIn_ID004_carries_the_hybrid_interim_trigger()
{
// Pins the JSON → model deserialization of evaluateOn end-to-end through the
// embedded catalog (rules/dist/analyze-rules.json).
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-ID-004");
Assert.Equal(new List<string> { "enrollment_end", "on_event:hybrid_login_pending" }, rule.EvaluateOn);
// Interim safety: the rule must not fire on a single overdue-login signal — the
// threshold has to require the repetition factors (base 60 + count>=2 → 80).
Assert.True(rule.ConfidenceThreshold > rule.BaseConfidence,
"ANALYZE-ID-004 must be repetition-gated to stay interim-safe");
}
[Fact]
public void BuiltIn_catalog_evaluateOn_triggers_are_all_grammatical_and_unblocked()
{
foreach (var rule in BuiltInAnalyzeRules.GetAll())
{
if (rule.EvaluateOn is not { Count: > 0 })
continue;
foreach (var trigger in rule.EvaluateOn)
{
Assert.True(AnalyzeRuleTriggers.IsValidTrigger(trigger),
$"Rule {rule.RuleId} ships invalid evaluateOn trigger '{trigger}'");
if (trigger.StartsWith("on_event:", StringComparison.OrdinalIgnoreCase))
{
var eventType = trigger.Substring("on_event:".Length);
Assert.False(AnalyzeRuleTriggers.IsBlockedOnEventType(eventType),
$"Rule {rule.RuleId} ships hard-blocked evaluateOn trigger '{trigger}' — the runtime would silently drop it");
}
}
}
}
// ── Hard-blocked high-frequency trigger types ───────────────────────────
[Fact]
public void Blocked_on_event_types_are_dropped_by_the_runtime_matcher()
{
// Defense in depth: even a persisted row carrying a blocked trigger is inert.
var rule = new AnalyzeRule
{
EvaluateOn = new List<string> { "on_event:performance_snapshot", $"on_event:{ProbeEventType}" },
};
Assert.Equal(new[] { ProbeEventType }, AnalyzeRuleTriggers.OnEventTypes(rule));
Assert.False(AnalyzeRuleTriggers.MatchesOnEvent(rule, new[] { "performance_snapshot" }));
}
[Fact]
public async Task Create_rule_with_blocked_on_event_trigger_is_rejected()
{
var repo = new Mock<IRuleRepository>();
repo.Setup(r => r.GetAnalyzeRulesAsync(It.IsAny<string>())).ReturnsAsync(new List<AnalyzeRule>());
repo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var rule = ProbeRule("ANALYZE-CUSTOM-901",
evaluateOn: new List<string> { "enrollment_end", "on_event:performance_snapshot" });
rule.IsBuiltIn = false;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CreateRuleAsync(TenantId, rule));
Assert.Contains("blocked", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("performance_snapshot", ex.Message);
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public void Blocked_set_matches_the_guardrails_json_mirror()
{
// Enforcement is code-only (AnalyzeRuleTriggers.BlockedOnEventTypes); the JSON mirror
// feeds the MCP validate_rule pre-flight. This parity test keeps the two from drifting —
// an addition on either side fails here until the other side follows.
var guardrailsPath = Path.Combine(FindRepoRoot(), "rules", "guardrails.json");
var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(guardrailsPath));
var mirror = json["blockedInterimTriggerEventTypes"]!.ToObject<string[]>()!;
Assert.Equal(
AnalyzeRuleTriggers.BlockedOnEventTypes.OrderBy(t => t, StringComparer.Ordinal),
mirror.OrderBy(t => t, StringComparer.Ordinal));
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
// ── Fixture ─────────────────────────────────────────────────────────────
private sealed record Fixture(
RuleEngine Engine,
AnalyzeRuleService RuleService,
Mock<IRuleRepository> RuleRepo,
Mock<ISessionRepository> SessionRepo);
/// <summary>
/// Global partition mocked EMPTY (seed writes go to a black-hole mock), rules under test
/// live in the TENANT partition as custom rules — that keeps the embedded-catalog sunset
/// filter and the 43 shipped rules out of the picture entirely.
/// </summary>
private static Fixture CreateFixture(
List<AnalyzeRule> tenantRules, List<EnrollmentEvent> events, List<RuleResult>? existingResults = null)
{
foreach (var rule in tenantRules)
{
rule.IsBuiltIn = false;
rule.IsCommunity = false;
}
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantRules);
ruleRepo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(TenantId, SessionId))
.ReturnsAsync(existingResults ?? new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()))
.ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object,
NullLogger<RuleEngineEvaluateOnTests>.Instance);
return new Fixture(engine, ruleService, ruleRepo, sessionRepo);
}
/// <summary>Minimal rule that fires when an event of <paramref name="conditionEventType"/> exists.</summary>
private static AnalyzeRule ProbeRule(
string ruleId, List<string>? evaluateOn, string conditionEventType = ProbeEventType)
{
return new AnalyzeRule
{
RuleId = ruleId,
Title = $"Probe {ruleId}",
Severity = "warning",
Category = "device",
Enabled = true,
EvaluateOn = evaluateOn,
BaseConfidence = 60,
ConfidenceThreshold = 50,
Explanation = "probe",
Conditions = new List<RuleCondition>
{
new()
{
Signal = "probe",
Source = "event_type",
EventType = conditionEventType,
Operator = "exists",
Value = string.Empty,
Required = true,
},
},
};
}
private static List<EnrollmentEvent> ProbeEvents() => new()
{
new EnrollmentEvent
{
TenantId = TenantId,
SessionId = SessionId,
EventType = ProbeEventType,
Timestamp = new DateTime(2026, 8, 13, 9, 0, 0, DateTimeKind.Utc),
Message = "probe signal",
},
};
private static RuleResult StoredResult(
string ruleId, bool isInterim, DateTime? firstDetectedAt = null, DateTime? notifiedAt = null,
string? resultId = null)
{
return new RuleResult
{
ResultId = resultId ?? Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
RuleId = ruleId,
RuleTitle = $"Probe {ruleId}",
Severity = "warning",
Category = "device",
ConfidenceScore = 60,
Explanation = "probe",
DetectedAt = firstDetectedAt ?? new DateTime(2026, 8, 13, 10, 0, 0, DateTimeKind.Utc),
FirstDetectedAt = firstDetectedAt,
IsInterim = isInterim,
NotifiedAt = notifiedAt,
};
}
}
@@ -0,0 +1,35 @@
using AutopilotMonitor.Functions.Functions.Metrics;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Window parsing for the fleet-context deep-link endpoint
/// (<see cref="RuleHitSessionsFunction"/>, GET metrics/rule-hit-sessions).
/// Default 14 days; clamped to 1..90 because RuleResults follow the session
/// retention cascade — a larger window could never widen the hit set.
/// </summary>
public class RuleHitSessionsFunctionTests
{
[Theory]
[InlineData(null, 14)] // not supplied → default window
[InlineData("", 14)] // empty → default
[InlineData("garbage", 14)] // non-numeric → default
[InlineData("14", 14)]
[InlineData("1", 1)]
[InlineData("0", 1)] // below range → clamped up
[InlineData("-5", 1)]
[InlineData("90", 90)]
[InlineData("365", 90)] // above range → clamped down
public void ParseDays_Defaults_And_Clamps(string? raw, int expected)
{
Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw));
}
[Fact]
public void MaxSessionIds_Matches_Repository_Default_Cap()
{
// The truncated flag compares against this constant; it must stay in sync
// with the GetRuleHitSessionIdsAsync default cap (2000).
Assert.Equal(2000, RuleHitSessionsFunction.MaxSessionIds);
}
}
@@ -97,6 +97,49 @@ public class SafeWipeServiceTests
harness.Sut.WipeByTenantIdPropertyAsync("FakeTable", TenantId));
}
[Fact]
public async Task RowKey_AbortsWhenForeignRowKeyReturned()
{
// Variant D anchors on the RowKey (UsageMetrics: PK=date, RK=tenantId). The "global"
// aggregate row shares partitions with tenant rows — verify must reject anything whose
// RowKey is not exactly the tenant id.
var harness = new Harness(
queriedRows: new[]
{
new TableEntity("2026-08-01", TenantId),
new TableEntity("2026-08-01", "global"), // <-- must never be wiped
});
await Assert.ThrowsAsync<SafeWipeVerificationException>(() =>
harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId));
Assert.Empty(harness.SubmittedBatches);
Assert.Empty(harness.PerRowDeletes);
}
[Fact]
public async Task RowKey_DeletesAcrossPartitionsWithETagAll()
{
// One row per date partition — batches are grouped by PK, so 3 partitions = 3 batches.
var harness = new Harness(
queriedRows: new[]
{
new TableEntity("2026-08-01", TenantId),
new TableEntity("2026-08-02", TenantId),
new TableEntity("2026-08-03", TenantId),
});
var deleted = await harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId);
Assert.Equal(3, deleted);
Assert.Equal(3, harness.SubmittedBatches.Count);
foreach (var action in harness.SubmittedBatches.SelectMany(b => b))
{
Assert.Equal(TableTransactionActionType.Delete, action.ActionType);
Assert.Equal(ETag.All, action.Entity.ETag);
}
}
[Fact]
public async Task ExactPartition_DeletesEveryRowWithETagAll()
{
@@ -175,6 +218,8 @@ public class SafeWipeServiceTests
() => harness.Sut.WipeByDiscriminatorAndTenantPropertyAsync("t", "CodeLookup", "not-a-guid"));
await Assert.ThrowsAsync<ArgumentException>(
() => harness.Sut.WipeByTenantIdPropertyAsync("t", "not-a-guid"));
await Assert.ThrowsAsync<ArgumentException>(
() => harness.Sut.WipeByRowKeyAsync("t", "not-a-guid"));
}
// ── Harness ─────────────────────────────────────────────────────────────────
@@ -92,6 +92,129 @@ public class SessionAnnotationFunctionsTests
Assert.Equal(3, visible.Count);
}
// ── GET writableLanes: server-computed write matrix ─────────────────────
// The web renders lanes writable exactly per this list (it holds no matrix copy),
// so the GET must derive it from the SAME function the PUT re-gates with,
// including the own-tenant binding of the tenant-role lanes.
private static GetSessionAnnotationsFunction BuildGetFunction()
{
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
annotationRepo.Setup(r => r.GetForSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new List<SessionAnnotation>());
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
return new GetSessionAnnotationsFunction(
NullLogger<GetSessionAnnotationsFunction>.Instance,
annotationRepo.Object, sessionRepo.Object);
}
private static async Task<string[]> RunGetAndReadWritableLanes(RequestContext ctx)
{
var function = BuildGetFunction();
var req = BuildRequest(Principal(("tid", ctx.TenantId)), ctx, new { });
var res = await function.Run(req, SessionId);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
res.Body.Position = 0;
using var reader = new StreamReader(res.Body);
var json = JsonConvert.DeserializeAnonymousType(
await reader.ReadToEndAsync(), new { writableLanes = Array.Empty<string>() });
return json!.writableLanes;
}
[Fact]
public async Task Get_writableLanes_tenant_admin_own_tenant()
{
var lanes = await RunGetAndReadWritableLanes(TenantAdminContext());
Assert.Equal(new[] { AnnotationLanes.Operator, AnnotationLanes.TenantAdmin }, lanes);
}
[Fact]
public async Task Get_writableLanes_operator_own_tenant()
{
var lanes = await RunGetAndReadWritableLanes(new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "op@contoso.com",
UserRole = Constants.TenantRoles.Operator,
});
Assert.Equal(new[] { AnnotationLanes.Operator }, lanes);
}
[Fact]
public async Task Get_writableLanes_ga_on_foreign_session_gets_only_globaladmin_lane()
{
// GA is Admin of their HOME tenant; the session resolves to a foreign tenant —
// the home-tenant role must not leak, exactly like the PUT's 403 test above.
var lanes = await RunGetAndReadWritableLanes(new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999",
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "ga@fabrikam.com",
IsGlobalAdmin = true,
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
});
Assert.Equal(new[] { AnnotationLanes.GlobalAdmin }, lanes);
}
[Fact]
public async Task Get_writableLanes_global_reader_gets_none()
{
var lanes = await RunGetAndReadWritableLanes(new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999",
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "reader@fabrikam.com",
IsGlobalReader = true,
});
Assert.Empty(lanes);
}
// ── global list: GA-lane exclusion follows global scope ─────────────────
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
// delegated caller on its managed ?tenantId= path — the platform-internal
// globaladmin lane must be excluded server-side for anyone without global scope.
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task GlobalList_derives_globaladmin_lane_exclusion_from_global_scope(
bool hasGlobalScope, bool expectedExclude)
{
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
bool? capturedExclude = null;
annotationRepo
.Setup(r => r.QueryPageAsync(
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<int>(), It.IsAny<string?>(),
It.IsAny<bool>()))
.Callback<string?, string?, string?, string?, DateTime?, DateTime?, int, string?, bool>(
(_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
.ReturnsAsync((new List<SessionAnnotation>(), (string?)null));
var function = new ListSessionAnnotationsFunction(
NullLogger<ListSessionAnnotationsFunction>.Instance, annotationRepo.Object);
var requestCtx = new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "reader@contoso.com",
IsGlobalAdmin = hasGlobalScope,
};
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
Mock.Get(req).SetupGet(r => r.Url)
.Returns(new Uri("https://localhost/api/global/session-annotations"));
var res = await function.Run(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal(expectedExclude, capturedExclude);
}
// ── endpoint-level tests (fake HttpRequestData, same harness as
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
@@ -168,7 +291,7 @@ public class SessionAnnotationFunctionsTests
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -102,7 +102,7 @@ public class SessionEventsPaginationTests
// Regression: previously the function called BuildNextLink with the raw
// ?tenantId= query param, which is null when GA reads /api/sessions/{id}/events
// without explicitly passing tenantId. Page 1 then bound the token to the
// resolved tenant (via FindSessionTenantIdAsync), but the nextLink omitted
// resolved tenant (via ResolveSessionTenantIdAsync), but the nextLink omitted
// it — so page 2 validated against the GA's JWT tenant and the token was
// rejected as cross_tenant. Fix: nextLink now carries the resolved tenantId
// verbatim, and the function honours ?tenantId= on follow-up pages for GA.
@@ -0,0 +1,84 @@
using System;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Fragilitätsaudit P6.2: server-side free-text search (<c>q=</c>) for the dashboard search
/// box, replacing the client's unbounded loadAll() sweep. The predicate must mirror the web
/// dashboard's client-side searchable field set (minus derived-only tokens) — see the doc on
/// <c>TableStorageService.MatchesFreeText</c>.
/// </summary>
public class SessionFreeTextSearchTests
{
private static SessionSummary Sample() => new()
{
SessionId = "22222222-2222-2222-2222-222222222222",
TenantId = "11111111-1111-1111-1111-111111111111",
DeviceName = "DESKTOP-A1B2C3",
SerialNumber = "5CG1234XYZ",
Manufacturer = "Contoso",
Model = "EliteBook 840",
Status = SessionStatus.Failed,
GeoCountry = "DE",
GeoRegion = "Hessen",
GeoCity = "Frankfurt",
AgentVersion = "2.0.1400",
OsName = "Windows 11",
OsBuild = "26100.1000",
OsDisplayVersion = "24H2",
OsEdition = "Enterprise",
OsLanguage = "de-DE",
};
[Theory]
[InlineData("a1b2")] // device name, case-insensitive substring
[InlineData("5cg1234")] // serial
[InlineData("contoso")] // manufacturer
[InlineData("elitebook")] // model
[InlineData("failed")] // status text
[InlineData("2222-2222")] // sessionId fragment
[InlineData("frankfurt")] // geo city
[InlineData("2.0.1400")] // agent version
[InlineData("26100")] // os build
[InlineData("24h2")] // os display version
public void MatchesFreeText_FindsSubstringAcrossDashboardFields(string q)
{
Assert.True(TableStorageService.MatchesFreeText(Sample(), q));
}
[Theory]
[InlineData("tailspin")]
[InlineData("succeeded")]
[InlineData("99999")]
public void MatchesFreeText_RejectsNonMatches(string q)
{
Assert.False(TableStorageService.MatchesFreeText(Sample(), q));
}
[Fact]
public void MatchesFreeText_EmptyQuery_MatchesEverything()
{
Assert.True(TableStorageService.MatchesFreeText(Sample(), null));
Assert.True(TableStorageService.MatchesFreeText(Sample(), string.Empty));
}
[Fact]
public void Fingerprint_BindsQ_SoContinuationCannotCrossQueries()
{
var withQ = new SessionSearchFilter { Q = "desktop" };
var otherQ = new SessionSearchFilter { Q = "laptop" };
var noQ = new SessionSearchFilter();
const string caller = "11111111-1111-1111-1111-111111111111";
var fp1 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, withQ);
var fp2 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, otherQ);
var fp3 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, noQ);
Assert.NotEqual(fp1, fp2);
Assert.NotEqual(fp1, fp3);
}
}
@@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Fragilitätsaudit P5.2: <see cref="SessionIndexFieldManifest"/> is the declared SessionsIndex
/// column set; builder, reader, and merge guard are all pinned against it here.
/// <list type="bullet">
/// <item>Builder ↔ manifest bidirectional: a field added to <c>BuildSessionIndexEntity</c> but
/// not the manifest fails, and vice versa.</item>
/// <item>Reader coverage: every manifest field must influence <c>MapToSessionSummary</c> output —
/// a projected-but-never-served column is dead weight; a served-but-not-projected one is
/// the drift bug.</item>
/// <item>Full-rebuild upsert is Replace-mode, so conditional fields the primary cleared do not
/// survive as stale index columns.</item>
/// </list>
/// </summary>
public class SessionIndexFieldManifestTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private const string IndexRowKey = "2516000000000000000_" + SessionId;
private static readonly DateTime StartedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly string[] SystemKeys = { "PartitionKey", "RowKey", "Timestamp", "odata.etag" };
/// <summary>
/// One distinctly non-default sample value per manifest field. A new manifest field without
/// an entry here fails the tests with a clear message — extend this map alongside the
/// manifest. Values are typed the way the WRITER stores them (wire form).
/// </summary>
private static readonly Dictionary<string, object> SampleValues = new(StringComparer.Ordinal)
{
// SessionId sample deliberately differs from the RowKey suffix so the reader test can
// detect that the stored property (not the RowKey fallback) is being served.
["SessionId"] = "33333333-3333-3333-3333-333333333333",
["SerialNumber"] = "SN-0001",
["DeviceName"] = "PC-0001",
["Manufacturer"] = "Contoso",
["Model"] = "Elite-1",
["StartedAt"] = new DateTimeOffset(StartedAt),
["Status"] = "Failed",
["CurrentPhase"] = 3,
["CurrentPhaseDetail"] = "phase-detail-1",
["EventCount"] = 42,
["EnrollmentType"] = "v2",
["IsPreProvisioned"] = true,
["IsHybridJoin"] = true,
["IsUserDriven"] = true,
["IsSelfDeployingProfile"] = true,
["IsCloudPc"] = true,
["AgentVersion"] = "2.0.1400",
["ImeAgentVersion"] = "1.2.3.4",
["OsName"] = "Windows 11",
["OsBuild"] = "26100.1000",
["OsDisplayVersion"] = "24H2",
["OsEdition"] = "Enterprise",
["OsLanguage"] = "de-DE",
["GeoCountry"] = "DE",
["GeoRegion"] = "HE",
["GeoCity"] = "Frankfurt",
["GeoLoc"] = "50.11,8.68",
["PlatformScriptCount"] = 2,
["RemediationScriptCount"] = 3,
["RebootCount"] = 4,
["ExcessiveEventsAlerted"] = true,
["ExcessiveEventsAutoActioned"] = true,
["CompletedAt"] = new DateTimeOffset(StartedAt.AddMinutes(45)),
["FailureReason"] = "esp timeout",
["FailureSource"] = "ime",
["ReconcileReason"] = "late completion",
["EspSoftFailure"] = true,
["CompletionSource"] = "esp_exit",
["ValidatedBy"] = "AutopilotV1",
["FailureSnapshotJson"] = "{\"x\":1}",
["AdminMarkedAction"] = "failed",
["DurationSeconds"] = 360,
["DiagnosticsBlobName"] = "diag-0001.zip",
["DiagnosticsBlobDestination"] = "Hosted",
["LastEventAt"] = new DateTimeOffset(StartedAt.AddMinutes(44)),
["ResumedAt"] = new DateTimeOffset(StartedAt.AddMinutes(10)),
["StalledAt"] = new DateTimeOffset(StartedAt.AddMinutes(20)),
["AvgApiLatencyMs"] = 513.5,
["ApiRequestCount"] = 100,
["ConnectionType"] = "Ethernet",
};
// ── Manifest shape ───────────────────────────────────────────────────────────
[Fact]
public void Manifest_IsInternallyConsistent()
{
Assert.Empty(SessionIndexFieldManifest.AlwaysProjected
.Intersect(SessionIndexFieldManifest.ConditionallyProjected, StringComparer.Ordinal));
Assert.Equal(SessionIndexFieldManifest.All.Length,
SessionIndexFieldManifest.All.Distinct(StringComparer.Ordinal).Count());
Assert.Empty(SessionIndexFieldManifest.PrimaryOnly
.Intersect(SessionIndexFieldManifest.All, StringComparer.Ordinal));
}
[Fact]
public void EveryManifestField_HasASampleValueInThisTest()
{
var missing = SessionIndexFieldManifest.All
.Where(f => !SampleValues.ContainsKey(f))
.ToList();
Assert.True(missing.Count == 0,
"New manifest field(s) without a sample value in SessionIndexFieldManifestTests.SampleValues:\n - "
+ string.Join("\n - ", missing));
}
// ── Builder ↔ manifest (bidirectional) ───────────────────────────────────────
[Fact]
public void FullyPopulatedRow_BuilderOutputMatchesManifestExactly()
{
var idx = TableStorageService.BuildSessionIndexEntity(FullSessionRow(), IndexRowKey, StartedAt);
var dataKeys = DataKeys(idx);
var missingFromBuilder = SessionIndexFieldManifest.All.Except(dataKeys, StringComparer.Ordinal).ToList();
var missingFromManifest = dataKeys.Except(SessionIndexFieldManifest.All, StringComparer.Ordinal).ToList();
Assert.True(missingFromBuilder.Count == 0,
"Manifest fields BuildSessionIndexEntity did not project from a fully-populated row:\n - "
+ string.Join("\n - ", missingFromBuilder));
Assert.True(missingFromManifest.Count == 0,
"BuildSessionIndexEntity projects fields missing from SessionIndexFieldManifest:\n - "
+ string.Join("\n - ", missingFromManifest));
}
[Fact]
public void MinimalRow_BuilderOutputMatchesAlwaysProjectedExactly()
{
var minimal = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(StartedAt),
};
var idx = TableStorageService.BuildSessionIndexEntity(minimal, IndexRowKey, StartedAt);
var dataKeys = DataKeys(idx);
var missing = SessionIndexFieldManifest.AlwaysProjected.Except(dataKeys, StringComparer.Ordinal).ToList();
var unexpected = dataKeys.Except(SessionIndexFieldManifest.AlwaysProjected, StringComparer.Ordinal).ToList();
Assert.True(missing.Count == 0,
"AlwaysProjected fields absent from a minimal-row rebuild (default missing in builder):\n - "
+ string.Join("\n - ", missing));
Assert.True(unexpected.Count == 0,
"Fields written for a minimal row although classified ConditionallyProjected (or unlisted):\n - "
+ string.Join("\n - ", unexpected));
}
// ── Reader coverage ──────────────────────────────────────────────────────────
[Fact]
public void EveryManifestField_InfluencesMappedSessionSummary()
{
var sut = NewMapperService();
var baseline = Serialize(sut.MapIndexEntityToSessionSummary(MinimalIndexEntity()));
var silent = new List<string>();
foreach (var field in SessionIndexFieldManifest.All)
{
var entity = MinimalIndexEntity();
entity[field] = SampleValues[field];
var mapped = Serialize(sut.MapIndexEntityToSessionSummary(entity));
if (mapped == baseline)
silent.Add(field);
}
Assert.True(silent.Count == 0,
"Manifest fields whose value does NOT surface in MapToSessionSummary — either the reader "
+ "ignores the column (dead projection) or the sample value needs adjusting:\n - "
+ string.Join("\n - ", silent));
}
// ── Merge guard ──────────────────────────────────────────────────────────────
[Fact]
public void FindNonManifestKeys_FlagsOnlyForeignDataKeys()
{
var merge = new TableEntity(TenantId, IndexRowKey)
{
["Status"] = "Failed",
["RebootCount"] = 2,
["BrandNewField"] = "x",
};
var offenders = SessionIndexFieldManifest.FindNonManifestKeys(merge);
Assert.Equal(new[] { "BrandNewField" }, offenders);
}
[Fact]
public void FindNonManifestKeys_EmptyForPureManifestMerge()
{
var merge = new TableEntity(TenantId, IndexRowKey)
{
["EventCount"] = 7,
["LastEventAt"] = new DateTimeOffset(StartedAt),
};
Assert.Empty(SessionIndexFieldManifest.FindNonManifestKeys(merge));
}
// ── Full-rebuild upsert semantics ────────────────────────────────────────────
[Fact]
public async Task UpsertSessionIndex_UsesReplaceMode()
{
// Replace (not merge) is what heals stale conditional columns after the primary
// cleared them — merge-mode upsert cannot remove a column.
var harness = new UpsertHarness();
await harness.Sut.UpsertSessionIndexAsync(FullSessionRow(), StartedAt);
harness.Index.Verify(t => t.UpsertEntityAsync(
It.Is<TableEntity>(e => e.RowKey.EndsWith("_" + SessionId, StringComparison.Ordinal)),
TableUpdateMode.Replace,
It.IsAny<CancellationToken>()),
Times.Once);
// IndexRowKey written back onto the Sessions row (merge).
harness.Sessions.Verify(t => t.UpdateEntityAsync(
It.Is<ITableEntity>(e => e.PartitionKey == TenantId && e.RowKey == SessionId),
It.IsAny<ETag>(),
TableUpdateMode.Merge,
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpsertSessionIndex_DeletesStaleRowKey_WhenStartedAtShifted()
{
var staleRowKey = "9999999999999999999_" + SessionId;
var row = FullSessionRow();
row["IndexRowKey"] = staleRowKey;
var harness = new UpsertHarness();
await harness.Sut.UpsertSessionIndexAsync(row, StartedAt);
harness.Index.Verify(t => t.DeleteEntityAsync(
TenantId, staleRowKey, It.IsAny<ETag>(), It.IsAny<CancellationToken>()),
Times.Once);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// <summary>
/// A Sessions row carrying every known field. Deliberately built from SampleValues (the
/// test-local list), NOT from the manifest — deriving the input from the manifest would
/// make the bidirectional check circular: a field dropped from the manifest would also
/// vanish from the input row and the drift would pass unnoticed.
/// </summary>
private static TableEntity FullSessionRow()
{
var row = new TableEntity(TenantId, SessionId);
foreach (var (field, value) in SampleValues)
{
// SessionId comes from the RowKey; the builder never reads a SessionId property.
if (field == "SessionId")
continue;
row[field] = value;
}
return row;
}
private static TableEntity MinimalIndexEntity() => new(TenantId, IndexRowKey);
private static List<string> DataKeys(TableEntity entity)
=> entity.Keys.Except(SystemKeys, StringComparer.Ordinal).ToList();
private static string Serialize(object o) => JsonSerializer.Serialize(o);
private static TableStorageService NewMapperService()
=> new(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
private sealed class UpsertHarness
{
public Mock<TableClient> Sessions { get; } = new();
public Mock<TableClient> Index { get; } = new();
public TableStorageService Sut { get; }
public UpsertHarness()
{
Index
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
Index
.Setup(t => t.DeleteEntityAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
Sessions
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
}
}
@@ -92,6 +92,36 @@ public class SessionRestoreServiceTests
Assert.Equal(3, result.InventoryReIncrements);
}
[Fact]
public async Task Restore_routes_tombstone_rows_by_explicit_table_not_rowkey_shape()
{
var harness = new Harness();
harness.SetCompletedCascade();
// Adversarial fixture: each row's explicit Table CONTRADICTS the legacy RowKey-shape
// heuristic (Sessions RK carries '_', SessionsIndex RK is bare). New manifests always
// carry Table — a call-site regression back to Contains('_') routing flips both
// tables and this test goes red. (BuildFullManifest's tombstone rows deliberately
// stay Table-less to keep exercising the legacy fallback in the other facts.)
var tombstone = harness.Manifest.Steps.First(s => s.Class == DeletionStepClass.Final);
tombstone.Rows = new List<DeletionRowDump>
{
new DeletionRowDump { Pk = TenantId, Rk = "bare-index-key", Table = Constants.TableNames.SessionsIndex },
new DeletionRowDump { Pk = TenantId, Rk = $"under_scored_{SessionId}", Table = Constants.TableNames.Sessions },
};
var result = await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com");
Assert.Equal(SessionRestoreOutcome.Restored, result.Outcome);
harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync(
Constants.TableNames.SessionsIndex,
It.Is<IReadOnlyList<DeletionRowDump>>(rows => rows.Count == 1 && rows[0].Rk == "bare-index-key"),
RestoreMode.Full, It.IsAny<CancellationToken>()), Times.Once);
harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync(
Constants.TableNames.Sessions,
It.Is<IReadOnlyList<DeletionRowDump>>(rows => rows.Count == 1 && rows[0].Rk.StartsWith("under_scored_")),
RestoreMode.Full, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Restore_full_re_increments_software_inventory_via_contributions()
{
@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using RequestContext = AutopilotMonitor.Functions.Helpers.RequestContext;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Fragilitätsaudit P5.3/P6.1: <c>RequestContextExtensions.ResolveSessionScopeAsync</c> is the
/// single implementation of the former 15-site copy-paste session-scope fallback, and
/// <c>TableStorageService.ResolveSessionTenantIdAsync</c> replaces the cross-partition
/// SessionsIndex scan with a SessionTenantLookup point-read (legacy scan fallback + self-heal).
/// These tests pin the semantics every migrated endpoint now inherits.
/// </summary>
public class SessionScopeResolutionTests
{
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
private const string OtherTenant = "44444444-4444-4444-4444-444444444444";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
// ── RequestContextExtensions.ResolveSessionScopeAsync ───────────────────────
[Fact]
public async Task NonGlobalCaller_KeepsTargetTenant_WithoutTouchingTheRepo()
{
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(HomeTenant, result);
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task GlobalScope_CrossTenantSession_ResolvesToOwningTenant()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(OtherTenant, result);
}
[Fact]
public async Task GlobalScope_OwnTenantSession_KeepsTargetTenant()
{
// Case-insensitive comparison: a resolver returning a different casing must not
// count as "different tenant".
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(HomeTenant.ToUpperInvariant());
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(HomeTenant, result);
}
[Fact]
public async Task GlobalScope_UnknownSession_KeepsTargetTenant_SoEndpointNotFoundHandlingStaysInCharge()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync((string?)null);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(HomeTenant, result);
}
[Fact]
public async Task RequireGlobalAdmin_ExcludesGlobalReader_FromCrossTenantWrites()
{
// Write paths (annotation upsert) gate the resolve on IsGlobalAdmin: a read-only
// Global Reader keeps the middleware-validated tenant and can never steer a write
// into a foreign tenant.
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
Assert.Equal(HomeTenant, result);
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
Assert.Equal(OtherTenant, result);
}
[Fact]
public async Task DelegatedCaller_GetsNoFallback_ByDesign()
{
// Deliberate: delegated (MSP) callers are NOT global scope — extending the fallback
// to AllowedTenantIds would widen the authz surface and is a separate user decision.
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
var ctx = new RequestContext
{
TenantId = HomeTenant,
TargetTenantId = OtherTenant,
IsDelegatedReader = true,
AllowedTenantIds = new[] { OtherTenant },
};
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(OtherTenant, result);
repo.VerifyNoOtherCalls();
}
// ── TableStorageService.ResolveSessionTenantIdAsync ─────────────────────────
[Fact]
public async Task PointReadHit_ReturnsTenant_WithoutScanningTheIndex()
{
var harness = new ResolveHarness(lookupRow: LookupRow(OtherTenant));
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Equal(OtherTenant, result);
harness.Index.Verify(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task PointReadMiss_FallsBackToScan_AndHealsTheLookupRow()
{
var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId };
var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow });
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Equal(OtherTenant, result);
// Self-heal: the lookup row is written so the next resolve is a point-read.
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.Is<TableEntity>(e => e.PartitionKey == SessionId
&& e.RowKey == "tenant"
&& e.GetString("TenantId") == OtherTenant),
It.IsAny<TableUpdateMode>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UnknownSession_ReturnsNull_AndWritesNoHealRow()
{
var harness = new ResolveHarness(lookupRow: null, indexRows: Array.Empty<TableEntity>());
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Null(result);
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task InvalidSessionId_Throws_BeforeAnyStorageCall()
{
var harness = new ResolveHarness(lookupRow: null);
await Assert.ThrowsAsync<ArgumentException>(
() => harness.Sut.ResolveSessionTenantIdAsync("not-a-guid"));
}
// ── RecomputeTriggerGate ─────────────────────────────────────────────────────
[Theory]
[InlineData("Admin", true)]
[InlineData("Operator", true)]
[InlineData("Viewer", false)]
[InlineData("", false)]
public void OwnTenantRoles_GateRecompute(string role, bool expected)
{
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = role };
Assert.Equal(expected, RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
}
[Fact]
public void GlobalAdmin_MayRecompute_CrossTenant()
{
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalAdmin = true };
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
}
[Fact]
public void GlobalReader_MayNotRecompute_Anywhere()
{
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalReader = true };
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
}
[Fact]
public void AdminRole_DoesNotCarryCrossTenant()
{
// A tenant Admin viewing a FOREIGN tenant's session (delegated read) must not be able
// to trigger recomputes there — the role binds to the caller's own tenant.
var ctx = new RequestContext { TenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
}
// ── Harness ──────────────────────────────────────────────────────────────────
private static TableEntity LookupRow(string tenantId)
=> new(SessionId, "tenant") { ["TenantId"] = tenantId };
private sealed class ResolveHarness
{
public Mock<TableClient> Lookup { get; } = new();
public Mock<TableClient> Index { get; } = new();
public TableStorageService Sut { get; }
public ResolveHarness(TableEntity? lookupRow, TableEntity[]? indexRows = null)
{
if (lookupRow != null)
{
Lookup
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(lookupRow, Mock.Of<Response>()));
}
else
{
Lookup
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
}
Lookup
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
var page = Page<TableEntity>.FromValues(indexRows ?? Array.Empty<TableEntity>(), null, Mock.Of<Response>());
Index
.Setup(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
}
}

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