Commit Graph

786 Commits

Author SHA1 Message Date
Oliver Kieselbach 993ebbaa67 perf(agent-v2): RegistryWatcher on ThreadPool wait + disposal hygiene
Reduce the V2 agent footprint during enrollment without touching any
capability or the deliberate live-tracking cadences (DO 3s poll, IME
100ms poll, download_progress ImmediateUpload — all product USP).

RegistryWatcher: replace the dedicated background thread (new Thread +
WaitHandle.WaitAny on RegNotifyChangeKeyValue) with a ThreadPool wait
(ThreadPool.RegisterWaitForSingleObject), mirroring the proven pattern
in Monitoring/Telemetry/Office/RegistryChangeWatcher. Removes up to
~7-8 dedicated ~1MB thread stacks (Enrollments + CloudDomainJoin +
AadJoin + Provisioning + up to 4x RealmJoin). The public contract is
preserved verbatim: ctor signature, Changed/Error events, IsRunning,
Start()/RequestStop()/Stop() (incl. deferred rethrow of the captured
background failure) and Dispose(). Arming stays synchronous in Start();
Changed is raised outside the lock; the watcher re-arms after each
single-shot notification. All four consumers run unchanged.

Disposal hygiene:
- WmiCollector: dispose each ManagementObject per iteration (native
  WMI/COM memory) via using (obj).
- AgentSelfMetricsCollector: wrap both Process.GetCurrentProcess() in
  using var (handle leak in the 60s metrics loop).

Verified: V2.Core + V2 agent build clean; full V2.Core suite 1264/1264
green, incl. the RegistryWatcher Win32 integration tests (value write,
subtree subkey, repeated successive changes) and the RealmJoin /
AadJoin / Provisioning / TenantId consumer tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:31:20 +02:00
Oliver Kieselbach 403d042f18 updated agent changelog 2026-06-09 10:52:12 +02:00
Oliver Kieselbach cd6dfe7c3c feat(office): render Office as an install row + fix started/completed ordering
UI — Office in Install Progress:
- The office_install_* lifecycle now renders in the existing InstallProgress
  card as a first-class install row (live elapsed timer while installing +
  final duration). page.tsx feeds office_install_started/completed/failed into
  the component; InstallProgress maps them onto the same Installing/Installed/
  Failed flow via isStarted/isCompleted/isFailed (Office has no postponed/
  skipped; appName "Microsoft 365 Apps").

Fix — completed-before-started (field session a7525e97, Office CSP / Win32
wrapper):
- When Office is already on disk as the C2R worker runs (CSP install / a Win32
  package that re-runs C2R), the host's OfficeBinaryWatcher initial scan
  completes the lifecycle synchronously. BeginIfIdle armed that watcher (via
  ObserveInstallationPath) BEFORE emitting started, so completed was emitted
  first. Reorder: emit started, then observe the path — guaranteed started ->
  completed ordering. Start is still gated by real evidence (process / registry
  Scenario\INSTALL / DO job); only the emit order changed.
- InstallProgress defensive guard: a late started on an already-Installed item
  backfills startedAt and recomputes durationMs instead of dropping it, so
  out-of-order / network-reordered delivery still yields a correct duration.

V2-only + web. ConfigVersion unchanged. V2.Core 1264/1264, web 452/452, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:11:44 +02:00
Oliver Kieselbach e922ab7bf0 feat(office): filesystem-watcher completion + drop noisy progress event
Rev 4 of the Office detector, from field session 7da7dead (agent 2.0.980):
early DO-CDN start worked, but there was no completion and the progress
events were useless.

Completion (was never firing):
- The DO-"ended" heuristic is structurally unreliable for Office — many
  parallel CDN jobs churn, finished ones drop out of the list, so the
  aggregate never reaches 100% and the job count never cleanly hits zero.
  onOfficeDownloadEnded never fired, so the completion/binary check never ran.
- Replace it with an OfficeBinaryWatcher: once the InstallationPath is known
  from the registry, a FileSystemWatcher on {InstallationPath} fires when any
  core Office binary (WINWORD/EXCEL/POWERPNT/OUTLOOK.EXE — any-of, products can
  be excluded) appears on disk -> TryFinalizeCompletion emits completed. Initial
  on-arm scan + bounded retry-arm cover armed-late / dir-not-yet-present. The
  whole DO-ended close machinery (host close/settle/probe timer,
  collector onOfficeDownloadEnded) is removed.

Progress (was noise):
- Connected-Cache delivery is near-instant and the multi-job aggregate never
  yields a clean 0..100 bar (7da7dead showed one/five identical 62% events).
- Drop office_install_progress emission entirely. Only started / completed /
  failed now. The valuable Delivery-Optimization data ships ONCE as a doSummary
  on the completed event: total bytes + Cache-Server / peer / pure-CDN split
  (CDN = BytesFromHttp - BytesFromCacheServer on MCC networks) + download mode,
  alongside durationSeconds and versionReached.

Log spam:
- The registry watcher fires on every ClickToRun value churn; 7da7dead logged
  ~4000 "Office install expected" INFO lines. Now OfficeExpected is only raised
  until the lifecycle starts, and NotifyOfficeExpected logs at Debug and only
  when the probe window re-opens from closed.

V2-only. ConfigVersion unchanged (29). The office_install_progress constant +
MCP/web catalog entries are left in place (now never emitted; optional prune).
V2.Core 1263/1263 green; V2 runtime builds. The FileSystemWatcher itself is
OS-bound and not unit-tested (consistent with the process/registry watchers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:34:59 +02:00
Oliver Kieselbach 735b27db52 feat(office): detect Office install from DO-CDN job + filesystem completion
Rev 3 of the Office detector. Stops anchoring the lifecycle on the late,
transient OfficeC2RClient.exe worker (field sessions 8353e03b / 58d52632:
the 3.4 GB Office-CDN download ran ~10 min BEFORE the worker appeared, which
then lived ~5 s — so we only ever saw the download tail, never completion).

Early detection (no new polling):
- DeliveryOptimizationCollector now classifies Office-CDN jobs UNCONDITIONALLY
  (dropped the process-driven _officeActive gate). The download is already
  visible in the IME-driven polls long before the worker. An event-driven wake
  (NotifyOfficeExpected, fired when the registry shows Scenario\INSTALL) covers
  the isolated-Office case via a bounded probe window.

Earliest-signal start, paired:
- office_install_started now fires on the first of {Office-CDN DO job,
  Scenario\INSTALL registry, OfficeC2RClient.exe} — idempotent, one lifecycle,
  with startedTrigger recorded in the payload.

Real completion (filesystem proof):
- On window close (download ended AND worker gone), the host runs a bounded
  completion probe: read Configuration\InstallationPath and, if any core Office
  binary (WINWORD/EXCEL/POWERPNT/OUTLOOK.EXE) exists under {InstallationPath}\
  root\* (version folder enumerated, not hardcoded), emit office_install_completed.
  The integrate phase lays binaries down after the stream ends, so the probe
  retries (settle, then <=8x15s). No on-disk proof -> stop silently (never a
  false completed/failed). Explicit error code -> failed (unchanged).

Wiring: OfficeInstallDetectorHost arms the RegistryChangeWatcher at Start (not
worker-start) and owns the close/probe schedule + an OfficeExpected event; the
DO host/factory take the whole officeHost and route SubmitDoSample /
NotifyOfficeDownloadEnded / OfficeExpected.

V2-only. ConfigVersion unchanged (29). 29 Office tests; V2.Core 1265/1265 green.
Field-validate next enrollment (startedTrigger=do at download start, completed
with coreBinariesPresent); Scenario\INSTALL pre-download timing is TODO(office-followup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:22:46 +02:00
Oliver Kieselbach a188e7cdb2 fix(office): pace office_install_progress to observable movement only
Field session 58d52632 showed a burst of 5 office_install_progress events
within the same second, all carrying an identical, frozen 62% download —
not real progress.

Root cause: Office progress has two triggers, the 3s DO poll and the subtree
RegNotifyChangeKeyValue push on the ClickToRun key. During an active C2R
operation the worker rewrites the undocumented Scenario\* values many times
per second, firing RegNotify each time. BuildProgressSignature included the
raw ScenarioValues dict, so every churn passed the dedup gate and emitted a
progress event — while the folded DO download-% stayed frozen (the DO poll
had not re-sampled within that 1s window) and the raw scenario values are
never surfaced in the payload. Pure noise.

Fix: key the progress dedup signature only on the observable progress
dimensions we actually surface and that represent real forward movement —
VersionToReport, the active scenario name, and the DO download (bytes /
percent). Progress now paces naturally to the DO sampling cadence; the
registry push still drives error-code discovery (and, later, TasksState
phase detection).

V2-only. 22/22 Office detector tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:38:46 +02:00
Oliver Kieselbach 01de1790e0 fix(agent): stop Office detector false-failed; defer completion to TasksState capture
Field session 8353e03b declared a successful Office install as failed. Root cause
(diag zip + do-status.jsonl + registry):
- OfficeC2RClient.exe is a transient launcher (~2s in the field) that kicks off the
  operation; the OfficeClickToRun.exe service installs for minutes after it exits.
  Treating worker-exit as completion → false office_install_failed.
- The registry has NO StreamingFinished value (so the completion branch could never
  fire and the "abandoned" branch always did), and Scenario\INSTALL persists
  post-install (so presence is not an "active" signal).
- Office C2R content comes from f.c2r.ts.cdn.office.net AND officecdn.microsoft.com;
  the old DO matcher only checked "officecdn" and would never attribute Office jobs.

Conservative fix (completion/failure detection deferred until a live-install capture
of Scenario\INSTALL\TasksState transitions — the real signal: per-task
TASKSTATE_COMPLETED/_INPROGRESS):
- OnWorkerStopped no longer finalizes — emits no terminal. started + progress only.
- Removed the StreamingFinished-based completed/abandoned logic; only an explicit
  numeric error code finalizes as failed.
- phase label is "Installing" (cannot distinguish Streaming/Finalizing without a real
  signal).
- DO Office matcher now keys on the stable CDN hosts cdn.office.net +
  officecdn.microsoft.com (dropped the version-fragile _16_0_ FileId marker).

V2 suite 1257/1257 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:21:02 +02:00
Oliver Kieselbach cf8fadfc17 fix(rules): drop AutoLogon false-positive on normal Autopilot enrollments
ANALYZE-SEC-002 fired Warning (confidence 90) on every normal user-driven
Entra-join enrollment. During ESP, Windows writes AutoAdminLogon=1 +
DefaultUserName + AutoLogonSID + no DefaultPassword for the enrolling user —
the exact fingerprint the sysinternals_autologon_suspected heuristic targeted
(enabled && user && !password). AutoLogon-enabled is registry-indistinguishable
from a Sysinternals/kiosk setup during Autopilot, so the heuristic and SEC-002
could never tell a benign enrollment from a real one.

Declare AutoLogon only when a plaintext password is actually present:
- delete ANALYZE-SEC-002 (broad AutoLogon-active warning)
- remove the non-discriminating sysinternals heuristic from AutoLogonAnalyzer
  (computation + finding label + checks.sysinternals_autologon_suspected)
- strip the sysinternals condition+factor from SEC-004 (kiosk allow-list, still
  a disabled opt-in template) and refresh its now-stale SEC-002 guidance
- ANALYZE-SEC-003 (plaintext DefaultPassword -> high) is now the sole default-on
  AutoLogon grade; the autologon_analysis event still carries all raw facts at Info

Tests: assert SEC-002 absent + no rule references the sysinternals signal; add
analyzer regression guard for enabled+user+no-password. V2-only, no ConfigVersion
bump. Build 0 err, AutoLogon 19/19, BuiltInRulesTests 8/8, web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:06:53 +02:00
Oliver Kieselbach adc8078de9 fix(agent): silence CS8600 nullable warnings in Office host wiring
#nullable enable: declare officeHost as nullable and make the no-Office
onOfficeDoSample fallback a nullable cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:21:02 +02:00
Oliver Kieselbach dbfdc9b239 feat(agent): event-driven Office (M365 Apps C2R) install detector + DO telemetry
Surfaces the real Microsoft 365 Apps Click-to-Run install lifecycle that the
Intune "integrated" app hides (IME reports done in 1-2 min while C2R keeps
streaming in the background). V2-only.

Event-driven, no idle polling:
- OfficeProcessWatcher: WMI Win32_ProcessStartTrace push on OfficeC2RClient.exe
  (+ startup probe) for start; Process.Exited for stop, debounced by a settle
  window (OfficeInstallSettleSeconds, default 10s) so short worker gaps in one
  install don't trigger a premature office_install_failed.
- RegistryChangeWatcher: RegNotifyChangeKeyValue push on ...\Office\ClickToRun
  (Configuration value + Scenario subkey churn); bootstrap fallback watches the
  nearest existing ancestor non-recursive + name-only until ClickToRun appears.
- OfficeInstallDetector: pure state machine (Idle->Active->Terminal), emits
  office_install_started/progress/completed/failed (progress on real change only,
  no heartbeat). Error code only from non-zero numeric/hex Scenario values.

Office Delivery Optimization:
- DeliveryOptimizationCollector extended with a 2nd wake source (Office active),
  classifies non-IME Office-CDN jobs and folds aggregated DO bytes/peers/real
  download-% into the office_install_* events (not emitted as download_progress
  to avoid a phantom app in the backend AppInstallSummary).

Plumbing: office_install_* event-type constants, EnableOfficeInstallDetector +
OfficeInstallSettleSeconds config (ConfigVersion 28->29), MCP + web event-type
catalogs, ANALYZE-OFFICE-001 (office_install_failed -> Warning). 22 detector unit
tests; full V2 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:17:53 +02:00
Oliver Kieselbach a9c8ae42f3 update agent changelog 2026-06-07 15:45:18 +02:00
Oliver Kieselbach 425502c419 feat(ppkg): array-aware analyze rules + allow-list + size-chunked scan event
Reworks PPKG detection to one aggregate event the rules parse as an array,
adds a default + customizable allow-list, and makes the event storage-safe.

Rule engine:
- New condition source event_data_array + ItemField: iterates an array field
  (any-element match), with dot-path dataField support. (RuleEngine.ConditionEvaluators)

Agent:
- Drop per-package provisioning_package_detected events (noise: a clean Win11 VM
  ships ~22 OS-inbox .ppkg). One provisioning_package_scan carries an artifacts[]
  array; anyPpkgFound derived from it (consistent, incl. recovery residue).
- Size-based chunking: split artifacts across multiple scan events so each event's
  Newtonsoft DataJson stays < 24k (well under the 30k Table-Storage truncation that
  would blind the rule); never drops a tail artifact. Per-artifact string fields
  (identity/dir/rank/installTime) hard-capped at 256; scanErrors capped.

Rules:
- ANALYZE-SEC-005 (default on) + SEC-006 (template) fire on artifacts.identity
  not matching an anchored allow-list (^(?:...)\b) that covers OS-inbox families.
- ANALYZE-SEC-007 alarms when a per-source collection cap was hit (scanTruncated).

Web/MCP/docs: event_data_array in the rule form (types + fields), analyze-rules
docs section + example, event-type catalogs.

Tests: rule-engine array/anchor/dot-path/SEC-007; agent artifacts/chunking/field-cap
/worst-case-size. Build green; V2.Core 1236; backend rule + web tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:14:51 +02:00
Oliver Kieselbach 01fac29bcb feat(agent): AutoLogon detector + analyze rules (V2)
V2-only AutoLogonAnalyzer that reads the Winlogon AutoLogon configuration and
emits an autologon_analysis event with raw facts at Info severity. AutoLogon can
be a legitimate kiosk/shared-device setup or a way to automate enrollment/OOBE;
the agent reports facts only and never reads the DefaultPassword value (presence
detected via value-name enumeration). Backend analyze-rules grade the facts.

- AutoLogonAnalyzer (always-on, no tenant toggle): startup no-op, runs at
  DeviceSetup-phase completion (new AutoLogonDeviceSetupTrigger on
  EspAndHelloHost.DeviceSetupProvisioningComplete) and at final shutdown.
- Sysinternals Autologon heuristic (AutoLogon enabled + default user + no
  registry password = LSA-secret credential); never reads the LSA secret.
- Analyze rules: ANALYZE-SEC-002 (AutoLogon enabled -> warning, default-on),
  ANALYZE-SEC-003 (plaintext DefaultPassword -> high), ANALYZE-SEC-004 (kiosk
  allow-list template). Sysinternals graded via a real confidence condition
  inside SEC-002/004 so the allow-list still suppresses approved kiosks.
- autologon_analysis added to all event-type catalogs; rule-builder picker also
  gains the previously-missing integrity_bypass_analysis + local_admin_analysis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:09:52 +02:00
Oliver Kieselbach 2900b35256 feat(agent): provisioning-package (PPKG) detector + analyze rules (V2)
V2 PPKG scanner that runs once at DeviceSetup-phase start and reports raw facts
only — .ppkg file presence, Provisioning\Packages registry metadata,
Recovery\Customizations residue, and best-effort per-package content indicators.
PPKGs can come from legitimate bulk enrollment or be a pre/in-enrollment
manipulation vector; intent is judged by backend analyze-rules, not the agent.

- ProvisioningPackageCollector + ProvisioningPackageHost, wired as a kernel host
  in DefaultComponentFactory (one-shot at DeviceSetup start, not at agent start).
- Events: provisioning_package_scan (aggregate) + provisioning_package_detected
  (one scalar event per artifact so analyze rules can match per package).
- Analyze rules ANALYZE-SEC-005/006 incl. a per-tenant allow-list template.
- Event-type catalogs kept in sync (Constants.EventTypes, MCP resource-catalog,
  rule-builder eventTypes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:06:08 +02:00
Oliver Kieselbach 9855860f1c feat(mcp): interpolate {{token}} rule-template placeholders in analysis output
MCP-side mirror of the web interpolateRuleTemplate: resolve
{{reason}}/{{appName}}/{{errorCode}}/... from each rule result's
matchedConditions before get_session_details and get_session_summary
ship the explanation + remediation text, so MCP tool output never leaks
raw {{...}} placeholders. Resolution order is kept in lock-step with the
web copy (byField -> whitelisted auto-field -> signal name).

Also rewords the ANALYZE-ENRL-001 footnote to explain that a remaining
literal {{...}} means the field simply was not recorded for this session
(version 1.1.0 -> 1.1.1; compiled dist and the rule-version assertion in
RuleEngineDetectionFailureTests updated to match).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:40:09 +02:00
Oliver Kieselbach 7626c06303 fix(agent-v2): enforce real hard timeout in StallProbeCollector source scans
RunWithTimeout ran each source synchronously on the long-lived
IdleCheckCallback worker and only measured elapsed time afterward, so a
wedged EventLogReader/registry/file read would block that worker forever
- despite the XML docs, the per-source timeout constants, and the inline
comment all promising a hard cap.

Each source now runs on a dedicated IsBackground thread joined with its
timeout. On timeout the worker is abandoned (read-only, dies with the
process) and the source marked failed; remaining sources still run. To
avoid an orphan racing the main thread's list reads, scans write into an
isolated scratch ProbeResult, merged into the live result only on
in-time, successful completion. Cross-source caps (MaxRawSamples,
MaxActiveInstalls) extracted into constants and enforced during merge.

V2.Core suite 1189/1189 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:52 +02:00
Oliver Kieselbach 39ffa93ee7 fix(agent-v2): emit non-failure subcategory_state_change transitions
DetectSubcategoryTransitions silently dropped every non-failure, non-completion,
non-noise subcategory transition (e.g. succeeded -> in_progress retry, failed ->
in_progress recovery, in_progress -> notStarted reset) due to a dead inner
'if (isFailure)' guard that is always false after the outer failure/completion
branches. The esp_provisioning_status event downgraded to a plain progress update
without a subcategory_state_change changeType. Record those as non-failure
transitions so they surface; add regression coverage.
2026-06-05 20:54:54 +02:00
Oliver Kieselbach bbcd5cedd4 fix(health-scripts): stop compliant detections being stamped script_failed/Error
Intune health-script detections (scriptType=remediation, scriptPart=detection/
post-detection) that report complianceResult=True, exitCode=0 were emitted as
script_failed/Severity=Error whenever the detection PowerShell leaked a benign
probe line to stderr (e.g. Get-ChildItem path-not-found). On green sessions this
inflated errorCount (8 on a successful session) and ranked the rows #1 in
search_events, diluting usage.

Root cause: the hasStderr check short-circuited the failure decision before the
existing isHealthComplianceReport exemption. Exit-code was already exempt for
detection phases; stderr was not. The flawed rule was hand-duplicated in the
agent emit and the web reducer, and propagated into MCP errorCount + ranking.

Fix (verdict is authoritative for detection/post-detection phases):
- Agent ImeLogTrackerAdapter.EmitScriptCompleted: fold hasStderr under
  !isHealthComplianceReport. Platform scripts, the remediation phase itself, and
  explicit result="Failed" still route to script_failed/Error.
- Web reducer scriptExecutions.ts: same exemption (kept in sync). Non-compliant
  detections render amber via isNonCompliantReport, not red.
- MCP read-side guard isBenignHealthDetectionReport() in tools/shared.ts, applied
  in search.ts severityIntentFactor (damp 0.6, no failure boost) and sessions.ts
  errorCount/keyEvents. Non-destructive; de-pollutes already-stored rows before
  the agent build rolls out per-enrollment.

Tests: Agent 70 pass (retargeted enrichment test + new compliant-stderr
regression), MCP search 61 pass (helper + ranker damp), Web reducer 66 pass
(flipped detection-stderr case). MCP + Web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:44:12 +02:00
Oliver Kieselbach e89faa0e8c chore(agent-v2): bump build counter 911->918 2026-05-30 13:32:40 +02:00
Oliver Kieselbach 469c692866 refactor(events): single-source event-type catalog in Constants.EventTypes + drift guards
Event types were defined in 3 hand-maintained, drifted copies: Constants.EventTypes
(C#, incomplete — most types were raw string literals at ~70 V2 emit sites),
KNOWN_EVENT_TYPES (MCP search), and EVENT_TYPES_CATALOG (MCP resource). The MCP catalog
advertised 6 phantom types that are never emitted and omitted ~80 real ones, silently
degrading the now event-TYPE-driven cross-session search recall.

Make Constants.EventTypes the ONE canonical source:
- Add 60 consts (49 V2/both + 11 legacy V1 types kept so historical V1 sessions stay
  analyzable). Refactor ~70 V2 emit sites from literals to consts (values byte-identical;
  persisted strings matched by rules/UI/MCP). V1 emit sites left untouched (decommissioning).
- Guard test (V2): scan V2 source for the 6 emit shapes, assert every literal is defined in
  Constants.EventTypes — prevents re-drift. A deeper sweep this test validated also caught 11
  emitted types the initial audit missed (local-var emit shape): whiteglove_part1_complete,
  hello_*, hello_wizard_started, esp_failure, esp_exiting.
- MCP: resource-catalog.ts becomes the single TS source (130 types, 21 groups);
  KNOWN_EVENT_TYPES derives from it; shadow_discrepancy marked internal (searchable, not
  advertised). Drift test reads Constants.cs and asserts catalog ∪ internal === C# values.

Pure refactor — no event-type string value changed. Tests: DecisionCore 357/357,
V2.Core 1186/1186 (incl. guard), MCP 109/109, all tsc/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:24:15 +02:00
Oliver Kieselbach 6728dc474d feat(device): CPU architecture (x86/x64/ARM/ARM64) as hardware property + MCP-queryable
Architecture rides the existing hardware_spec telemetry event (alongside
CPU/RAM/GPU), so backend storage needs zero changes - it lands in the generic
Props_hardware_spec JSON blob and flows through the dynamic prop.* filter.

- Agent V2: CollectCpuInfo() adds Architecture to the existing Win32_Processor
  SELECT and emits cpuArchitecture via new MapProcessorArchitecture helper
  (0->x86, 5->ARM, 6->ia64, 9->x64, 12->ARM64). 64-bit WMI host reports true
  HW arch even under ARM64 emulation, unlike PROCESSOR_ARCHITECTURE.
- Backend: trailing-* prefix wildcard in MatchesDevicePropertyFilter so
  cpuArchitecture=ARM* matches ARM + ARM64 in one query (generic, exact/numeric
  behavior unchanged).
- Web: Architecture DetailRow in DeviceDetailsCard hardware section.
- MCP: resource-catalog entry + wildcard hint in search_sessions description.
- Tests: ProcessorArchitectureMappingTests (9), DevicePropertyFilterWildcardTests (15).

V2-only; old V1 agents and pre-existing sessions simply lack the key (no backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:57:54 +02:00
Oliver Kieselbach 8e26eb15e6 fix(rules): make detection-failure result actionable for end users
Session 080edee9 follow-up — the analyze cards the user saw on re-analysis
were neither accurate (Slow Network / App Download Stalled both fired as
false positives) nor specific (Enrollment Failed never named the offending
app; ANALYZE-APP-013 never fired). Five coordinated changes, all
version-bumped so EnsureBuiltInRulesSeededAsync re-imports them on the next
backend boot — the title/severity/version-check filter does not pick up
condition-only diffs:

ANALYZE-APP-013 v1.1.0 — fire on legacy + new agent shape:
  Previous rule keyed on `failureType == esp_apps_detection_failure`,
  which is only emitted by agents that shipped AFTER 4dd050e0. Historical
  sessions (and any agent on the old build) won't match.
  Replace with a precondition + condition pair that works for BOTH:
    * precondition: esp_failure_settle_started.errorCode == 0x87d1041c
      (V2 ProvisioningStatusTracker emits this on every Apps subcategory
      failure with that HRESULT, regardless of agent version)
    * required: app_install_failed.detectionResult == NotDetected
      (ImeLogTracker sets this from the IME's DetectionActionHandler line
      across all V2 agents)
    * optional: esp_failure_settle_started.errorCode (for {{errorCode}}
      interpolation; appName comes via auto-field from the required match)

ANALYZE-ENRL-001 v1.1.0 — restore appName + cross-link to APP-013:
  My previous trim dropped the failed_app condition on the theory that the
  auto-field path would carry it. That was wrong — auto-fields come from
  the FIRST matched condition's evidence, which here is `enrollment_failed`
  (no appName). Add back the optional failed_app condition with
  dataField=appName so byField resolves {{appName}}. Explanation now leads
  with the failed app, lists reason/subcategory/errorCode, and cross-links
  to the actionable ANALYZE-APP-013 instead of "see other rule results".

ANALYZE-CORR-001 v1.1.0 ("Slow Network") — version bump for DB sync:
  Was already enabled:false in source but the DB still had it enabled (the
  seed mechanism only resyncs on title/severity/trigger/version diff, so
  the source `enabled` flip never propagated). Bump version → next deploy
  resyncs. Also adds a suppressByEvent on the app_failed condition so a
  later app_install_completed for the same appId suppresses the match (a
  retry that succeeded shouldn't count as failure).

ANALYZE-APP-009 v1.1.0 ("App Download Stalled") — same version bump for
DB sync + documents why a status-aware suppression isn't expressible yet:
  The V2 EnrollmentTerminationHandler synthesizes
  `download_progress: failed` events for promoted-stuck apps AFTER the
  real `download_progress: 100%` event already landed. The engine's
  suppressByEvent can't filter on a third event's status field, so
  status-aware suppression has to wait for either an engine extension or
  a dedicated `download_stalled` event type.

RuleEngineDetectionFailureTests — 6 new tests:
  * APP-013 fires on the historical 080edee9 event shape
  * APP-013 does NOT fire for non-Apps subcategory HRESULTs
  * APP-013 does NOT fire without esp_failure_settle_started (precondition)
  * ENRL-001 MatchedConditions carries appName + errorCode + subcategory
    + reason from three distinct event types (interpolator material)
  * CORR-001 + APP-009 shipped as disabled

Coverage: 2064/2064 backend, 413/413 web.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:53:02 +02:00
Oliver Kieselbach 5ee72db2e2 chore(agent-v2-tests): silence xUnit1012 by marking errorCode nullable
Codex review hygiene: the new
`AppStateChange_Error_promoted_carries_failureType_and_errorCode` Theory
passes null via [InlineData(EspAppsTimeout, null)] for the no-HRESULT
fallback case, but declared the parameter as non-nullable `string` (the
test project has <Nullable>enable</Nullable>). Switch to `string?` to
match the value flow and silence the xUnit1012 analyzer warning. Behaviour
unchanged — the test still asserts that the no-HRESULT branch omits the
DataJson.errorCode key entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:22:27 +02:00
Oliver Kieselbach dc0b9d7652 fix(agent-v2): gate HRESULT-based app classification on failedSubcategory=Apps
Codex review (P3): the V2 EnrollmentTerminationHandler previously cached
only the HRESULT from the last registry-derived ESP failure. A non-Apps
ESP failure carrying its own HRESULT (e.g. DeviceSetup/SecurityPolicies
or AccountSetup/CertificatesAccountSetup with their own status codes)
would then mis-classify still-installing apps as
`esp_apps_install_failure` — the HRESULT describes the failed category,
not the in-flight installs.

Replace `LastEspTerminalErrorCode` (string) with a new
`EspTerminalFailureSnapshot` value carrying errorCode + failedSubcategory
+ category as a single atomic snapshot. Plumb through the host stack
(EspAndHelloTracker -> EspAndHelloHost -> DefaultComponentFactory ->
EnrollmentTerminationHandler) and gate the HRESULT-driven classifier on
`snapshot.IsAppsSubcategory == true`. Non-Apps failures fall through to
the generic `esp_apps_timeout` classification with no HRESULT in the
promoted app's DataJson.

Coverage: 2 new gating tests + the 2 existing HRESULT tests updated to
supply the full snapshot (errorCode + "Apps" subcategory).
1176/1176 V2.Core, 2058/2058 backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:10:22 +02:00
Oliver Kieselbach 0859c05f4a fix(agent-v2): adapter surfaces all three AppFailureTypes on app_install_failed
Codex review (P1): ImeLogTrackerAdapter.BuildAppStatePayload only set
DataJson.failureType for `esp_apps_timeout`. After the HRESULT-aware
classifier landed (4dd050e0), apps promoted with `esp_apps_detection_failure`
or `esp_apps_install_failure` still landed with errorPatternId set but
failureType missing. Downstream consequences:

- ANALYZE-APP-013 (dataField: failureType, value: esp_apps_detection_failure)
  never matched.
- EventIngestProcessor.AppInstall set AppInstallSummary.FailureCode to the
  HRESULT instead of the canonical failureType.
- InstallProgress.tsx fell back to the generic "Failed" badge.

Extend the gate to cover all three AppFailureTypes constants and stamp
DataJson.failureType from app.ErrorPatternId. Adds a Theory test covering
all three flavours and a regression assertion that an unrelated IME-ERROR-*
pattern does NOT spuriously promote to failureType.

Coverage: 108/108 ImeLogTrackerAdapter tests, 1176/1176 V2.Core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:09:48 +02:00
Oliver Kieselbach 4dd050e05e fix(agent-v2): classify ESP Apps-subcategory failures by HRESULT
Session 080edee9 surfaced a long-standing mis-classification on the V2
EnrollmentTerminationHandler "still installing -> error" promotion path: every
terminal ESP-Apps failure was stamped as `esp_apps_timeout` with a message
claiming "ESP timed out (180 min)" - even when the actual session only ran
~27 min and the ESP terminated explicitly with HRESULT 0x87D1041C
("Application not detected after installation completed successfully"). The
"180 min" was the *configured* SyncFailureTimeoutMinutes ceiling, not the
elapsed time.

Plumb the HRESULT captured by ProvisioningStatusTracker through the host
stack (EspAndHelloTracker.LastEspTerminalErrorCode -> EspAndHelloHost ->
DefaultComponentFactory) into the termination handler, and route through a
new `AppFailureTypes.ClassifyEspAppsFailure` helper that produces:

- `esp_apps_detection_failure` for 0x87D1041C (install ran but Intune
  detection rule did not match afterwards - confirmed detection failure)
- `esp_apps_install_failure`  for any other HRESULT (installer reported
  an error before the app finished installing)
- `esp_apps_timeout`           fallback when no HRESULT is available
  (genuine "ESP gave up" case - now phrased as "configured at X min",
  not "timed out X min", to make clear it's a ceiling not an elapsed time)

The promoted `app_install_failed` event now carries DataJson.errorCode so
downstream rules and the UI can correlate the per-app failure to the
ESP-level HRESULT. AppPackageState.SetErrorContext already accepted an
optional errorCode argument, so this only extends the promotion delegate
signature without changing the per-app state shape.

Coverage: 86/86 Termination tests pass (incl. 2 new HRESULT-classification
cases); 6/6 new AppFailureTypesClassifierTests; 1171/1171 V2.Core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:41:07 +02:00
Oliver Kieselbach e0d4094a25 feat(agent-v2): capture mini-dumps + emit previous_crash_detected event
When the V2 agent process dies via AppDomain.UnhandledException or
TaskScheduler.UnobservedTaskException, synchronously write a compact
MiniDump (data-segs + handle + thread-info, ~5-20 MB) and a CrashRecord
JSON sibling to %ProgramData%\AutopilotMonitor\Crashes\. Next agent
start scans the directory, drains pending records into the timeline as
previous_crash_detected events (Warning, ImmediateUpload), and applies
retention (max 5 dumps, max 7 days). Dumps stay on disk so the existing
customer-opt-in diagnostics-upload can collect them.

Handlers register in Program.Main before SelfUpdater so any downstream
crash is captured; AgentRuntimeHost re-registers once session/tenant
are known so the CrashRecord carries those labels. 6 builder/retention
tests; 1163/1163 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:25:38 +02:00
Oliver Kieselbach d4acac9b11 refactor(shared,web): move error-codes catalog to single-source-of-truth
Lifts the Windows/MSI/Intune HRESULT catalog out of the web app into
src/Shared/AutopilotMonitor.Shared/Resources/error-codes.json so backend
and web load from the same authoritative file. Web syncs the JSON at
prebuild time. Adds ErrorCodeCatalog C# service with normalised-key
lookup (hex case, signed-decimal HRESULT, leading-zero padding) and a
parity test on both sides. No behavior change yet — this is groundwork
for backend read-time enrichment so MCP/API responses can carry
errorCodeInfo (description + confidence + source). 11 C# + 14 web tests
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:19:54 +02:00
Oliver Kieselbach 8851b67a09 feat(agent-v2): power-state probe at startup — warn on battery <80%
Adds a fail-soft GetSystemPowerStatus probe to StartupEnvironmentProbes,
emitting a power_state_check event into the enrollment timeline at agent
start. On battery with <80% charge is Warning, otherwise Info; desktops
(no battery) and probe failures handled explicitly. Gives admins early
visibility into a frequent driver behind power-management-related
enrollment stalls. 5 new builder tests; 1157/1157 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:41:23 +02:00
Oliver Kieselbach 5fb8496a5b added Fleet Exposure UI 2026-05-27 18:02:24 +02:00
Oliver Kieselbach 5d86d9e74f Audit table new default filter 2026-05-27 10:02:08 +02:00
Oliver Kieselbach dcb1ec2513 fix(agent/v2): RealmJoinWatcher drops pre-seed CheckUserPackages calls that leaked ESP-era user packages
The seed-at-arming fix (dcf89a83) only patched AttachUserRealmJoinSubtreeWatcher's
internal sweep. Two callers still ran a STANDALONE CheckUserPackages BEFORE the seed
pass: NotifyRealmJoinPresence (when phase crossed RunningThreshold with the SID
already set) and ArmHku (when desktop arrival landed after phase had already crossed
the threshold). The HKU dedup sets were empty at that point, so every pre-existing
user-hive package sub-key — typically RJ packages installed during ESP while
DeploymentPhase was still Blank — surfaced as a spurious started+completed pair
before the seed had any chance to suppress them.

Caught in session ff0cdcbe (V2 2.0.886): three user-scope RJ packages
(generic-agilebits-1password, generic-realmjoin-promote-tray-icon,
generic-vlc-usersettings) all fired started+completed at the same millisecond as
the Blank -> RunningFirstDeployment phase transition, exactly the
empty-dedup-set sweep signature.

Removed both standalone calls — EnsureUserRealmJoinStage ->
AttachUserRealmJoinSubtreeWatcher is now the single, ordered entry point
(seed -> watcher.Start -> CheckUserPackages, all post-seed). Added two regression
tests driving the production sequence with a fake registry that holds pre-existing
user packages; both assert zero RealmJoinPackageStarted/Completed signals. The
fake-registry wiring lands as an internal ctor with three delegate seams
(EnumeratePackageIds/TryReadPackage/KeyExists); the public ctor still binds to
RealmJoinInfo + the real Registry probe.

1152/1152 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:33:40 +02:00
Oliver Kieselbach dcf89a83f7 fix(agent/v2): RealmJoinWatcher seeds pre-existing package IDs at arming so only post-arming sub-keys surface
Pre-existing package sub-keys observed at arming time (typically RJ packages
installed during ESP before DeploymentPhase >= 100) were silently fired as
realmjoin_package_started by the initial CheckMachinePackages sweep. The
phase gate only prevented IME/RJ subtree-write misattribution; it did not
distinguish historical RJ rows from rows that genuinely appear after arming.

Attach now seeds both _*PackagesStarted and _*PackagesCompleted with every
sub-key already present under HKLM\SOFTWARE\RealmJoin\Packages (and the
HKU\<sid>\... mirror), without firing events. The seed runs inside the
state-lock BEFORE RegistryWatcher.Start() so no subtree wake-up can race
past it. Only sub-keys that appear AFTER arming surface as events.

Added 3 unit tests covering the ESP-leftover suppression, the
per-PackageId scope of the suppression (siblings still fire), and the
HKU mirror. Outdated comment on the immediate-arm test corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:19:25 +02:00
Oliver Kieselbach d2ec81d959 fix single shot fetch after terminal signal 2026-05-22 17:08:33 +02:00
Oliver Kieselbach 3d55aedb7e fix(agent/v2): defer RealmJoin package-watcher arming until DeploymentPhase >= 100
Previously the HKLM\SOFTWARE\RealmJoin (+ HKU\<sid>\...) subtree watchers attached the
moment the Services\realmjoin gate fired. The window between "RJ installed" and "RJ
actually deploying" is exactly when IME may still be writing to the same SOFTWARE\RealmJoin
subtree as part of unrelated package installs — those writes were being misattributed
to RJ's package lifecycle (realmjoin_package_started / _completed events for packages
RJ never deployed).

Defer the package watchers until RJ has moved out of Blank, i.e. DeploymentPhase >=
PhaseRunningThresholdMin (100 = RunningFirstDeployment, RunningFirstDeploymentAuto,
RunningDeployment, ManualDeployment, NestedFromShim, or terminal CompletedFirstDeployment).
The realmjoin_detected event still fires on service-key appearance (no change to the
detection gate); only the package-tracking surface stays disarmed for the pre-running
window.

- New const RealmJoinInfo.PhaseRunningThresholdMin = 100.
- Watcher state flag _packageWatchersArmed (set-once); old _realmJoinServiceDetected
  removed (no remaining readers).
- AttachServiceRealmjoinSubtreeWatcher no longer arms the package watchers — it only
  attaches the Services\realmjoin DeploymentPhase watcher and fires Detected.
- NotifyRealmJoinPresence arms HKLM + (if SID known) HKU package watchers on the first
  phase >= 100 observation; set-once so a later regressed reading does not disarm.
- ArmHku now defers on _packageWatchersArmed instead of the dropped service-detected flag.

Tests: +3 (disarmed-during-blank, set-once-after-running, immediate-arm-on-boot-at-110).
       14/14 RealmJoin adapter suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:39:03 +02:00
Oliver Kieselbach f6b2bedd69 feat(agent/v2): realmjoin_phase_changed surfaces RJ enum names alongside raw DeploymentPhase ints
The realmjoin_phase_changed message read like "DeploymentPhase 100 -> 110" — the raw
ints are correct but require a lookup table to interpret. Map them to the RJ-internal
DeploymentPhase enum names (Blank / RunningFirstDeployment[Auto] / CompletedFirstDeployment /
RunningDeployment / CompletedDeployment / ManualDeployment / NestedFromShim) so the
timeline + log message read like "DeploymentPhase RunningFirstDeployment -> CompletedFirstDeployment"
without a lookup. The numeric ints stay in the payload (deploymentPhase / previousPhase)
so KQL filters and dashboards do not have to switch.

- New RealmJoinInfo.PhaseDisplayName(int) — single switch matching the published RJ enum.
  Unknown / future values fall back to the numeric string so they stay observable instead
  of being collapsed to "Unknown".
- RealmJoinWatcherAdapter.OnPhaseChanged: payload now carries the two new
  deploymentPhaseName / previousPhaseName keys; message rendered with names.

Tests: +1 (mapping known phase ints → enum names + message format),
       existing phase-changed test extended to assert both raw + named fields.
       11/11 adapter suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:28:32 +02:00
Oliver Kieselbach 27612db560 fix(agent/v2): RealmJoinWatcher fires realmjoin_package_started on subkey observation, not on DisplayName presence
Today's RJ doesn't populate the DisplayName registry value for most package subkeys
(only ArgsHash / Success / LastExitCode / Version / Type are written — confirmed on
generic-vlc and generic-gkgab-set-wallpaper-preference). The old MaybeFirePackageEvents
gated the started signal on snap.HasStartedMarker (= DisplayName non-empty), so
realmjoin_package_started never fired for these packages and only realmjoin_package_completed
landed with an empty DisplayName slot.

Change: drop the HasStartedMarker guard from the started branch. The first observation
of a new <packageId> sub-key is itself the started signal, period. DisplayName stays in
the payload + message as a passthrough field — empty when RJ didn't advertise a name
(useful "RJ didn't write it" indicator on the wire) and populated automatically when a
future RJ version starts writing it at install-start. Completed semantics unchanged
(still gated on Success + LastExitCode presence).

Behavior for pre-existing/already-completed packages observed on agent boot: a single
MaybeFirePackageEvents pass now fires BOTH started AND completed (started first), so
the timeline reflects the full lifecycle even when we missed the live transition.

Tests: +2 watcher tests via the new TriggerMachinePackageObservationFromTest seam —
"fires started even without DisplayName" + "fires both started and completed for
pre-existing snapshot with completion markers". 10/10 adapter suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:27:40 +02:00
Oliver Kieselbach baf8ece79c feat(agent/v2,decision): include RealmJoin.exe ProductVersion in realmjoin_detected event
Surface the RJ host version alongside the existing detection signal so operators
can see at a glance "RJ detected, version x.y.z" without having to cross-reference
package logs.

Read path (RealmJoinInfo.TryReadRealmJoinProductVersion):
- FileVersionInfo.GetVersionInfo against C:\Program Files\RealmJoin\RealmJoin.exe.
- (x86) fallback path probed when the 64-bit install is absent.
- Defensive try/catch + File.Exists pre-check: a missing/locked/inaccessible
  binary returns null. Version is observability-only and must never block the
  detection signal — the realmjoin_detected event still emits without version.
- Called exactly ONCE per session, on the first Detected fire-once boundary.

Wire:
- RealmJoinDetectedEventArgs now carries an optional ProductVersion string.
- RealmJoinPayloadKeys.ProductVersion ("productVersion") plumbed through both
  the DecisionSignal payload and the InformationalEvent data dict + message tail.
- RealmJoinFacts.ProductVersion (SignalFact<string>, set-once) recorded on the
  reducer side so the enrollment_complete audit trail picks it up as
  realmjoinProductVersion via DecisionAuditTrailBuilder.

Tests: +2 adapter tests (with-version + without-version) — 12/12 green
       on the RealmJoin + SID suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:40:58 +02:00
Oliver Kieselbach caa573678f fix(decisioncore,agent/v2): defang ESP terminal failure when ContinueAnyway + AccountSetup entered
Session 4fa5a2d4 in tenant c9787ba2 hit 199/202 false-positive enrollment_failed events: ESP
profile sets BlockInStatusPage=6 (ContinueAnyway=true), Windows transitioned to AccountSetup,
then wrote DeviceSetupCategory=failed (Certificates) 13 min later — the reducer terminated
even though the device demonstrably kept progressing. PR1 Tier-1 defang downgrades these
EspTerminalFailure signals to a non-terminal esp_failure_advisory event while keeping
monitoring armed (Hello/Desktop/IME completion paths continue to drive the session).

DecisionEngine:
- New SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc on DecisionState as the fire-once
  gate (additive ctor param, optional at end — preserves Newtonsoft positional-binding for
  pre-PR1 v4 snapshots).
- HandleEspTerminalFailureV1: when ScenarioObservations.EspAllowContinueAnyway==true AND
  AccountSetupEnteredUtc!=null, emit esp_failure_advisory (severity=Warning,
  advisoryReason=esp_failure_defanged_continueanyway_with_accountsetup) instead of
  transitioning to Failed. Stage / deadlines / outcome stay untouched.
- Duplicate handling: a second EspTerminalFailure dead-ends UNLESS the duplicate carries
  registry-specific keys (errorCode/failedSubcategory/category) — those only appear in the
  ProvisioningStatusTracker path and indicate enrichment of an earlier ShellCoreTracker
  advisory. failureType alone is NOT a registry marker (both adapters emit it).
- New BuildEspFailureAuditTrail helper merges a whitelisted set of failure-context keys into
  the audit-trail typedPayload so HRESULT/subcategory survive the wire. Closes a pre-existing
  gap on the enrollment_failed path too.

Bootstrap:
- EnrollmentOrchestrator.PostEspConfigDetectedBootstrap switched from EspSkipConfigurationProbe.
  Read() (tuple SkipUser/SkipDevice) to ReadFull() so EspAllowContinueAnyway +
  EspSyncFailureTimeoutMinutes are synchronously available before any EspTerminalFailure
  signal — defeats the fire-and-forget DeviceInfoCollector.CollectAll race that would have
  left Tier 1 with ScenarioObservations.EspAllowContinueAnyway==null.

Tests:
- EspFailureAdvisoryDefangTests (9 new): advisory path, fire-once idempotency, enrichment
  on registry-detail second-fire, dead-end on ShellCore-only duplicate, three negative paths
  (ContinueAnyway false / unknown / no AccountSetup), TypedPayload whitelist merge on both
  advisory + Failed paths.
- SerializationTests: backcompat test for old v4 snapshots without EspAdvisoryFailureRecordedUtc.
- DecisionStateSnapshotBuilderTests: fact count bumped from 14 to 15.
- EdgeScenarioTests + esp-failure-advisory-continueanyway-v1.jsonl: end-to-end replay against
  the canonical Session 4fa5a2d4 signal stream including a duplicate dead-end.
- EnrollmentOrchestratorEspConfigBootstrapTests: 4 existing tests migrated to ScopedFullOverride,
  4 new tests cover ContinueAnyway + SyncFailureTimeout propagation, class gated behind
  [Collection("SerialThreading")] since the override targets a static probe slot.

UI:
- EventTimeline renders esp_failure_advisory with an amber "Advisory (ContinueAnyway)" badge
  alongside the HRESULT/subcategory chips so analysts can distinguish defanged from real
  failures at a glance.
- gather-rules/eventTypes.ts mirrors the new constant for autocomplete.

Build: dotnet build AutopilotMonitor.sln green; xUnit Backend(1937) + V2.Core(1139) +
Agent.Core(538) + DecisionCore(357) + SummaryDialog(44) = 4015 passed, 0 failed.
Web tsc clean, vitest 384/384 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:07:27 +02:00
Oliver Kieselbach 5eb95868d8 fix(decisioncore): defang SelfDeploying terminal via 5-min deadline + UI phase coverage
Session 88a53223-9795-4188-8352-7df9f0af9bb7 (v1 Classic UserDriven, AAD
Joined, SkipUserStatusPage=false) was wrongly classified as mode=SelfDeploying
after the ProvisioningStatusTracker 30-s fallback fired DeviceSetupProvisioningComplete
before Windows transitioned DeviceSetup -> AccountSetup. The signal alone was
terminal in the old reducer; the absence of AccountSetupEnteredUtc at signal
time was sufficient to commit terminal SelfDeploying.

New rule: DeviceSetupProvisioningComplete is no longer terminal. It records a
new DeviceSetupResolvedUtc anchor and (cancel-then-)arms the DeviceOnlyEspDetection
deadline for 5 min. The deadline-fired handler is the sole SelfDeploying-terminal
entry point and re-checks six guards (stale: no-anchor / not-armed / DueAtUtc-mismatch;
race: Stage-terminal / AccountSetup-entered / monotonic Mode conflict) before
classifying. Any dead-end after the DueAtUtc match state-side CancelDeadline()s
the past-due deadline so snapshot reload doesn't re-arm via the scheduler.

RealmJoin-deferred release (CompleteIfDeferredOrBookkeep) re-checks the same
guards, resets DeviceOnlyDeployment hypothesis to Unknown on bailout, and
operates on preparedBuilder.* (not state.*) so WithResolved / WithTimeoutOutcome
facts survive the bailout.

UI phase coverage closed: phase_transition(FinalizingSetup) and
phase_transition(Complete) emitted at every terminal-success site (Classic
FinalizingGrace, SelfDeploying deadline-terminal, AdminPreemption-Succeeded,
RJ-deferred completion) so the Web timeline opens Finalizing + Complete bars
universally - was missing entirely for SelfDeploying / AdminPreemption paths.

Other changes:
- Removed ApplyDeviceSetupProvisioningComplete (dead code under defang), added
  ApplySelfDeployingDeadlineConfirmed with monotonic guard.
- New RealmJoinFacts.ClearSelfDeployingDeferred() for the RJ-bailout path.
- DecisionState schema bumped v3 -> v4 with additive nullable
  DeviceSetupResolvedUtc field. StateSerializer v3-deserialize-compat verified.
- DeviceSetupProvisioningComplete added to DecisionStateSignalCensus so the
  enrollment_complete audit trail surfaces the anchor.

Test coverage:
- New SelfDeployingDeadlineTerminalTests.cs (12 scenarios): happy path, all
  six guards including snapshot-migration rollout race + DueAtUtc cancel-rearm
  race, replay idempotency, audit-census wiring, RJ-deferred release races
  (AccountSetup-arrives / monotonic / Timeout-variant).
- Updated 9 existing test files for new semantics; 2 fixtures now drive
  DeadlineFired terminal explicitly.
- ReplayHarness + ClassifierAwareReplayHarness set AgentBootUtc to the first
  signal's OccurredAtUtc so fixture scenarios run in their own time-bubble
  (replay-safety floor still tested directly by BootAnchorDeadlineFloorTests).

Tests: DecisionCore 345, V2.Core 1135, Backend 1937 - all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:42:10 +02:00
Oliver Kieselbach 48201aad62 fix(decisioncore): exempt InformationalEvent from post-terminal dispatch guard
The post-terminal guard added in 7de094c6 blanket-rejected every signal kind once
state.Stage reached Completed/Failed - including InformationalEvent, which is the
single-rail pass-through used by EnrollmentTerminationHandler to emit every
post-enrollment_complete lifecycle event (agent_shutting_down, app_tracking_summary,
shutdown-delta software_inventory_analysis, diagnostics_collecting/_uploaded,
enrollment_summary_shown, reboot_triggered, whiteglove_part1_complete). Result:
every V2 session on agent builds >= 2.0.835 ended at enrollment_complete with
zero terminal lifecycle events on the wire (sample: 0/11 affected vs 9/9 working
on <=2.0.820). The cross-path agent_shutting_down idempotency gate also masked
the failure - the handler's Emit returned without throwing, claimed the gate, so
the L4 runtime_host_exit fallback short-circuited.

HandleInformationalEventV1 does NOT mutate Stage/Outcome/facts, does NOT arm
deadlines, only emits an EmitEventTimelineEntry effect - so letting it through
post-terminal cannot regress the duplicate-enrollment_complete protection that
motivated the original guard (enrollment_complete on the FinalizingGrace path
is emitted directly from the reducer step, not via InformationalEvent).

Regression coverage: two new tests in FinalizingStageTests pinning that
InformationalEvent after Completed AND after Failed both produce the expected
EmitEventTimelineEntry effect with the original payload keys intact.

Tests: 326 DecisionCore + 1135 V2.Core green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:49:59 +02:00
Oliver Kieselbach 8aeadb3728 fix(agent/v2,decision): RealmJoin code-review findings — self-deadlock, Registry64, timeout idempotency
Three issues surfaced in review of the parent-watch refactor.

P1a — Watcher self-disposal deadlock in the appearance callback. Calling
RegistryWatcher.Dispose() from the Changed handler re-entered Stop() → Thread.Join()
on the same thread that owned the WatchLoop, hanging the watcher and skipping
onAppeared(). Fix: RequestStop() (non-blocking) inside the callback, then
ThreadPool.QueueUserWorkItem the actual Dispose so the join runs on a different
thread after the loop has exited.

P1b — All RealmJoin reads + watchers now explicitly bind to RegistryView.Registry64.
The agent's intent is 64-bit, but the previous RegistryView.Default would still
silently route HKLM\SOFTWARE through WOW6432Node if the host were ever spawned as a
32-bit process. Same defensive note already lives in TenantIdResolver. Affects
RealmJoinInfo.cs (3 reads + enumerations), RealmJoinWatcher.KeyExists, and all four
RegistryWatcher instantiations (1 parent-watch + 3 subtree-watches).

P2 — Stale RealmJoinTimeout DeadlineFired after Resolved. Race: the cancel-effect
for the timeout reaches the scheduler at the same time the deadline fires; the
queued DeadlineFired signal is processed after RealmJoinResolved has already set
Outcome="Resolved". Without a guard, the timeout handler still emitted a spurious
realmjoin_timeout timeline event and re-entered TransitionToFinalizing, duplicating
phase_transition(FinalizingSetup) on the wire and the FinalizingGrace deadline arm.
Fix: short-circuit to a bookkept dead-end transition when either Outcome is already
set OR the RealmJoinTimeout deadline is no longer in state.Deadlines (the cancel
took effect). DeadEndReason carries the specific cause for the Inspector.

Tests: DecisionCore 322 → 323 (new Stale_RealmJoinTimeout_after_Resolved_is_bookkept_dead_end_no_effects).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:49:45 +02:00
Oliver Kieselbach 97e8954339 fix(decisioncore): tighten Classic Hello-disabled FastPath to strong AccountSetup gate
The HelloDisabledFastPath in HandleDesktopArrivedV1 used a weak guard
(AccountSetupEnteredUtc != null) while HandleEspExitingV1 already used the
strong gate (ShouldTransitionToAwaitingHello: AccountSetupProvisioningSucceededUtc
OR ScenarioObservations.SkipUserEsp). Session 08c99638 exposed the asymmetry:
Shell-Core event 62407 fired esp_exiting with errorCode=0 while AccountSetup
categorySucceeded was still in_progress (0/N apps complete). A late
desktop_arrived then drove enrollment_complete via the weak guard.

The FastPath now reuses ShouldTransitionToAwaitingHello, restoring parity with
the EspExiting path. Sessions where ESP "exits" before AccountSetup genuinely
finishes stay parked in AwaitingDesktop and eventually fall to the max-lifetime
failure path.

Tests:
- HelloDisabled_FastPath_skipped_when_AccountSetup_entered_but_provisioning_never_succeeded
  (08c99638 repro)
- HelloDisabled_FastPath_fires_on_SkipUserEsp_flow_even_without_AccountSetupProvisioningComplete
  (SkipUser arm of the strong gate)

DecisionCore 324/324 + V2 Core 1135/1135 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:18:26 +02:00
Oliver Kieselbach a40c14b3a3 refactor(agent/v2): RealmJoinWatcher arms package watchers only after the Services gate fires
Previous staging armed HKLM\SOFTWARE\RealmJoin in parallel with the Services\realmjoin
parent-watch, and ArmHku (called on Desktop-Arrival) attached HKU\<sid>\SOFTWARE\RealmJoin
regardless of whether RJ was actually installed. Both are wasted observation surface on
the ~80% of devices that never get RJ — and HKU\<sid>\SOFTWARE in particular is a noisy
parent with many subkey changes per session.

Single entry point now: Services\realmjoin is the SOLE detection gate.

- Start() only arms the parent-watch on HKLM\SYSTEM\CurrentControlSet\Services.
- ArmHku(sid) records the SID but no HKU watcher is created until the gate fires.
- AttachServiceRealmjoinSubtreeWatcher (= "RJ detected") now also triggers:
    - EnsureMachineRealmJoinStage  (HKLM\SOFTWARE\RealmJoin subtree)
    - EnsureUserRealmJoinStage    (HKU\<sid>\SOFTWARE\RealmJoin subtree) — only when a SID
      was already recorded; otherwise the later ArmHku(sid) sees _realmJoinServiceDetected
      and arms immediately.
- Result on a non-RJ device: a single parent-watch on the Services hive, sleeping idle.
  No HKLM\SOFTWARE parent-watch, no HKU parent-watch.

Tests: 10/10 RealmJoin/SID tests still green (the trigger-from-test seams target the
read paths the gate ordering does not touch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:16:36 +02:00
Oliver Kieselbach 675a1bcb6b refactor(agent/v2): RealmJoin watcher uses parent-key Name-watch instead of 2s polling
The original watcher armed 2 s retry timers when the RJ keys did not exist yet — fine on
RJ-using devices, wasteful on the ~80% that never have RJ installed (10800 polls per 6 h
session). Switch to the canonical RegNotifyChangeKeyValue pattern: arm a Name-filter
notification on the always-existing parent and stay idle until the target sub-key
actually appears.

Per scope (machine + user) three stages now:
  1. Boot sync probe — if target already exists, jump straight to stage 3.
  2. Parent-watch on the always-existing root with Name filter:
       HKLM\SYSTEM\CurrentControlSet\Services   waits for `realmjoin`
       HKLM\SOFTWARE                            waits for `RealmJoin`
       HKU\<sid>\SOFTWARE                       waits for `RealmJoin`
  3. On appearance: parent-watch disposed; full subtree watcher attached on the new key
     with LastSet+Name filter. CheckParameters / CheckMachinePackages / CheckUserPackages
     fire under a debounce timer as before.

Side-effects:
- New constants in RealmJoinInfo for the always-existing parents + target sub-key names.
- The appearance event itself now fires RealmJoinDetected even before the
  Parameters\DeploymentPhase value is populated (phase=0 placeholder), matching the
  approved "Trigger: pure existence of the realmjoin service key" design choice.
- All retry timers removed; only the appearance + value debounce timers remain.

Tests: 10/10 RealmJoin/SID tests still green (the existing TriggerCheckXxxFromTest seams
exercise the same read paths the change preserves).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:11:12 +02:00
Oliver Kieselbach 2cf35eca5b chore(rules,agent): allowlist RealmJoin registry prefixes + bump build counters (Phase E)
- rules/guardrails.json: add new "RealmJoin" category covering
  SYSTEM\CurrentControlSet\Services\realmjoin and SOFTWARE\RealmJoin. Hive-agnostic
  match covers HKLM\SYSTEM\..., HKLM\SOFTWARE\RealmJoin, and HKU\<sid>\SOFTWARE\RealmJoin
  so operator-authored Gather-rules can probe the RJ keys (the RealmJoinWatcher
  itself bypasses GatherRuleGuards and watches the registry directly).
- src/Web/autopilot-monitor-web/lib/guardrails.generated.ts: regenerated via
  node rules/scripts/combine.js so the Web admin UI surface matches the new
  registry allowlist.
- buildcounter.txt (V2 + V1): auto-incremented by the Phase A-D builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:39:02 +02:00
Oliver Kieselbach bc02dad6bc test(decisioncore,agent/v2): RealmJoin gate, adapter, SID resolver tests + signal-kind fixtures (Phase D)
- DecisionCore.Tests/RealmJoinGateTests.cs (9 tests): baseline regression guard,
  RealmJoinDetected arms 60-min timeout, gate defers Finalizing on Classic AND-gate,
  RealmJoinResolved + Timeout both release through CompleteIfDeferredOrBookkeep,
  SelfDeploying terminal defers + direct-complete via SelfDeployingDeferredCompletion,
  per-package facts mutation, and audit-trail attaches realmjoin* fields.
- V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs (6 tests): each of the
  5 watcher events maps to the correct DecisionSignalKind + dual InformationalEvent;
  phase-changed is timeline-only; DisplayName clamped to 256 chars.
- V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs (4 tests): well-known
  BUILTIN\Administrators + NT AUTHORITY\SYSTEM SID round-trips, invalid/empty inputs
  return false.
- tests/fixtures/signal-kinds/realmjoin-{detected,resolved,timeout,package_started,
  package_completed}-v1.json: replay fixtures fulfilling the merge-block contract
  declared on DecisionSignalKind.cs:8.

Verified: 9/9 reducer + 10/10 adapter/SID tests green; no regressions in adjacent
suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:37:36 +02:00
Oliver Kieselbach 4b247f11a0 feat(agent/v2,shared): RealmJoin runtime + event-type catalog (Phase B+C)
Wire the V2 agent runtime that feeds the DecisionCore RealmJoin gate from Phase A.
On every Autopilot session the agent now observes the RJ deployment-state registry
keys and dual-emits DecisionSignals + InformationalEvents so the engine extends the
completion AND-gate while RJ is in flight.

Agent.V2.Core additions:
- RealmJoinInfo: registry constants + DeploymentPhase/Package readers
- UserSidResolver: advapi32 LookupAccountName + ConvertSidToStringSid P/Invoke
- RealmJoinWatcher: three RegistryWatchers (HKLM Parameters, HKLM Packages subtree,
  HKU\<sid> Packages subtree). Lazy attach with 2 s retry, 500 ms debounce, catch-up
  scan on Start() so pre-existing RJ state emits the matching one-shot events
  immediately. Strict dedup on Detected/Resolved/Timeout + (scope, packageId).
- RealmJoinWatcherAdapter: maps each watcher event to (1) a new DecisionSignalKind
  post + (2) a SharedConstants.EventTypes.RealmJoin* InformationalEvent. Package
  DisplayName truncated to 256 chars before posting.
- RealmJoinHost: ICollectorHost with compile-time kill-switch RealmJoinTrackingEnabled.
- DesktopArrivalDetector: new RealUserOwnerObserved event raised before DesktopArrived
  so the composition root can resolve the user's SID for the HKU watcher arm.
- DesktopArrivalHost: new onRealUserOwnerObserved callback param.
- DefaultComponentFactory: instantiates RealmJoinHost after AadJoinHost and routes the
  DesktopArrival owner through UserSidResolver -> RealmJoinHost.ArmHkuWatcher.

Shared additions:
- SharedConstants.EventTypes adds realmjoin_{detected,phase_changed,resolved,timeout,
  package_started,package_completed} so the engine + adapter + future tests all
  reference the same strings.
- DecisionEngine.RealmJoinPayloadKeys promoted from internal to public so the
  cross-assembly adapter can assemble signal payloads with the canonical key names.

Verified: full solution build clean; V2.Core tests 1124/1125 (the lone failure is the
documented WhiteGlove RecoveryPath flake — re-runs green in isolation, no Phase B link).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:29:38 +02:00
Oliver Kieselbach e8e062d24a feat(agent/v2,decision): surface ESP failure HRESULT + 30s settle window
Session 9d052230 dropped enrollment_failed with reason esp_terminal_failure
but the underlying HRESULT 0x87d1041c (Application not detected after install)
sat unparsed in the ESP subcategory statusText, and the agent terminated before
IME could emit the matching app_install_failed. Two-part fix:

- ProvisioningStatusTracker arms a 30s settle window between ESP-subcategory
  failure detection and EspFailureDetected fire. Gives ImeLogTracker a chance
  to surface the app-level failure on the timeline. Emits esp_failure_settle_started
  for observability.
- HRESULT extracted via language-invariant regex from the failed subcategory
  statusText, surfaced top-level on esp_provisioning_status (failedSubcategoryErrorCode)
  and propagated through new EspFailureDetectedEventArgs to the EspTerminalFailure
  signal payload. DecisionEngine.Edge writes failureType/errorCode/failedSubcategory/
  category onto the enrollment_failed event parameters. UI renders an HRESULT badge
  with description from errorCodeMap.ts (analogous to app_install_failed).

V2-only; V1 (Agent.Core) untouched. ConfigVersion unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:37:28 +02:00
Oliver Kieselbach a36bf8bf0c fix onboarding bug, onboardedBy and auto promote admin 2026-05-21 15:01:34 +02:00