Commit Graph

1958 Commits

Author SHA1 Message Date
Oliver Kieselbach 3ebeb644eb feat(mcp): resolve tenants by name or id directly in list_tenants
list_tenants gains two optional filter params so the model resolves a
tenant in ONE tool call instead of paging manually:

- query: case-insensitive substring match on domainName (and tenantId
  for pasted partial GUIDs), e.g. query="rewe" -> rewe-group.com
- tenantId: exact case-insensitive point lookup; wins over query

In filter mode the tool auto-paginates /api/config/all internally
(pageSize 1000, 10-page runaway budget -> `truncated` + recallNote if
ever hit) and returns only the matches. Filtering runs AFTER
extractTenantList secret-stripping and delegatedTenantListView MSP
scoping, so it can only narrow — never widen — what the caller may see.

Pure filterTenantList helper exported + 7 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:36:24 +02:00
Oliver Kieselbach d02fa454a7 fix(status): honest verdicts across all misclassification audit findings
Misclassification audit 2026-07-16 (1000 non-succeeded sessions, 22 deep
dives) surfaced six systematic misdeclaration sources. This implements
all of them:

1. DecisionCore: HelloNeverObservedPromote in the esp-exit advisory
   handler — when the Hello policy read never committed and no wizard
   was ever seen, hello_resolution is structurally unresolvable; promote
   to AwaitingHello + hello_safety instead of hard-failing a provably
   finished enrollment (sessions 2dac8298/7635ab18). Placed after the
   enforcement-progress re-arm so active installs still win; advisory-
   anchor variant stays exempt.

2. Ingest: both max-lifetime shapes (enrollment_failed/agent_timeout and
   agent_shutting_down/max_lifetime) now route through the
   EnrollmentTimeoutClassifier instead of hard Failed (tenant a53e67ec:
   fully provisioned WG Part 2, user never logged in => AwaitingUser).
   agent_timeout no longer counts as HasExplicitFailure; failure
   webhooks fire only on actual failure verdicts.

3. Maintenance sweep: dropped the too-broad "IsPreProvisioned ne true"
   exclusion (sealed devices are protected by their Pending status);
   resumed WG Part-2 runs sweep normally with the window anchored to
   ResumedAt. New stage-2 silence guard also fixes a latent bug where an
   actively-reporting >5h enrollment could be terminalized mid-run.

4. Registration supersede: a fresh registration resolves older
   non-terminal sessions of the same device (serial, placeholder-guarded)
   as Incomplete("Superseded by ...") with live SignalR updates — closes
   the forever-Pending orphan gap when WG Part 2 runs under a new id.

5. Admin retro-reconcile: POST /api/maintenance/reclassify-legacy
   (GlobalAdminOnly, DRY-RUN BY DEFAULT) re-runs the classifier over the
   pre-classifier "Session timed out" Failed backlog (~495 rows, sample
   projected ~87% misdeclared) and resolves Pending orphans. Requires the
   new opt-in allowTerminalReclassification override in the status
   writer (admin-marked rows stay untouchable).

6. Polish: "0 of N" AccountSetup rollups keep their total (no more
   fabricated "0/0" reasons, "never observed" wording when absent);
   late-telemetry reconcile heals Incomplete/AwaitingUser to Succeeded
   when straggler evidence proves completion; SLA page knows
   AwaitingUser/Incomplete ordinals; script_failed on Success/exit-0 now
   names the stderr rule in its message.

Tests: backend 2965, DecisionCore 474 (+6 new), agent 1777, web 557 —
all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:24:21 +02:00
Oliver Kieselbach 87cf893b03 feat(reconcile): surface silence duration + decision time in timeout reconcile
The "Backend reconciled" badge showed only the reason text with no timestamps,
so operators/customers could not tell "user finished setup and powered the
device off" apart from "the mechanism declared success too early" (session
efbc17ff): last-contact, silence window and the exact decision moment were all
invisible, and the stage-1 "Agent silent for Nmin" note is wiped by the
Succeeded-write reconcile hygiene.

- EnrollmentTimeoutClassifier.ClassifyTimedOutSession takes an optional
  lastEventAtUtc and appends a timing clause to the two Succeeded-reconcile
  reasons (AccountSetup-complete + desktop/Hello): last agent contact, silence
  duration, and the sweep's declaration time. Lives in the reason string so it
  survives the hygiene and reaches badge/MCP/exports.
- MaintenanceService passes session.LastEventAt through.
- Web: add Session.stalledAt; reconcile banner renders structured UTC anchors
  (last agent contact + first-flagged-silent with the observed silence gap).

Verified: build 0 errors; classifier 36/36 (2 new timing tests);
maintenance/status/projection 160/160; web tsc clean; vitest 557/557.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:35:50 +02:00
Oliver Kieselbach f0a5e550d3 ci: switch Azure deploy login to secret-less OIDC (federated credential)
deploy-backend + deploy-mcp now authenticate via azure/login OIDC (client-id/tenant-id/subscription-id + id-token permission) instead of the AZURE_CREDENTIALS service-principal secret. Adds temporary oidc-login-test workflow to smoke-test the federated login. Federated credential 'github-main' (repo:okieselbach/Autopilot-Monitor:ref:refs/heads/main) created on the deploy app registration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:47:25 +02:00
Oliver Kieselbach c649d80c86 fix(agent): harden single hardware read + reuse it for the session row
The agent read device hardware twice: once at startup via HardwareInfo
(feeds the security headers + telemetry) and again at registration via
DeviceInfoProvider (feeds the Sessions row). When WMI was transiently
unavailable during OOBE, the body read returned "Unknown" while the header
read had already captured real values — producing session rows with
Manufacturer/Model/SerialNumber = "Unknown" even though the backend
validated (and passed) real values from the headers.

- HardwareInfo.GetHardwareInfo: retry once (~1.5s) when any field is still
  unresolved, per-field merge across attempts, and isolate each WMI query so
  a BIOS-serial failure no longer discards manufacturer/model. Healthy
  devices resolve on attempt 1 and never sleep.
- SessionRegistrationHelper.BuildRegistration: accept the already-read
  hardware tuple and use it instead of re-querying WMI (falls back to
  DeviceInfoProvider only when none supplied, e.g. unit tests).
- BackendSessionRegistration: pass the auth bundle's hardware through so the
  session row matches the headers the backend validated against.

Net: one hardened WMI read instead of two, and the Sessions row can no
longer diverge from the security headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:32:10 +02:00
Oliver Kieselbach b0105b123a feat(maintenance): session-deletion run budget, live watchdog counters, manual trigger
- Watchdog fix: FanoutResult is caller-allocated and mutated live per
  tenant/session, so 30/60min LongRunning warnings report real progress
  instead of the always-zero counters (today's warning showed 0/0 while
  5 tenants / 54 sessions were already done).
- 50min run budget: the retention fanout stops cleanly at the deadline
  (tenant boundary + per-session check) and reports AbortedByBudget —
  no more risk of the 60min Flex functionTimeout hard-aborting mid-run.
  New Warning OpsEvent SessionDeletionMaintenanceBudgetExceeded;
  Completed gains abortedByBudget.
- Blob-lease serialization: dedicated sentinel
  _lock/session-deletion-maintenance.lock (deletion-manifests container)
  via SessionDeletionMaintenanceLockStore + reused MaintenanceLeaseHolder;
  contended runs emit SessionDeletionMaintenanceSkippedLocked and return.
- Manual trigger: POST /api/global/session-deletions/maintenance/trigger
  (GlobalAdminOnly, policy catalog) -> fail-hard queue producer ->
  SessionDeletionMaintenanceQueueWorker runs the same RunCoreAsync body
  with triggeredBy = requesting admin; lease probe returns 409 while a
  run is active. New Started OpsEvent carries triggeredBy.
- Web: MaintenanceStatusBanner on the Session Cleanup page derives
  active / budget-exceeded / failed / idle from the lifecycle ops events
  and offers a Run-now button (202/409 handling). New event types
  dual-registered in OPS_EVENT_TYPES.
- Tests: 7 new (budget abort immediate/mid-tenant, live counter
  increments, watchdog live-progress regression, BudgetExceeded +
  Completed flag, SkippedLocked, Started triggeredBy); suites green
  (backend 2935, web 557, tsc clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:22:31 +02:00
Oliver Kieselbach 8a824d73fc refactor(decision-core): EnrollmentScenarioObservations as record with with-expressions
The 8-parameter constructor plus 7 hand-threaded With* methods meant every new
observation field touched 8 copy sites - a threading-bug risk flagged in the
daily commit review (2026-07-14). Now a sealed record with init-only properties:
the With* methods keep the set-once guard but their bodies collapse to one-line
`this with { ... }` expressions, so the next field addition is one property plus
one With* method.

- IsExternalInit polyfill (internal) added: DecisionCore targets netstandard2.0,
  which lacks the C# 9 marker type for init setters. Consumers never need it -
  init/with usage stays inside the assembly.
- Snapshot compatibility unchanged: identical JSON shape (property names +
  SignalFact {Value, SourceSignalOrdinal}); the record deserializes via init
  setters instead of the removed constructor. Pinned by a new StateSerializer
  round-trip test covering facts, ordinals, and unset-stays-null.
- Semantics unchanged: set-once still returns `this` (Assert.Same tests hold);
  `==` is not used on the type anywhere, so record value equality is inert.

Verified: DecisionCore 468 tests, Agent V2 Core 1776 tests on net48 (the
consumer that mandated the netstandard2.0 target), Backend 2928 tests - all
passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:02:02 +02:00
Oliver Kieselbach 3a4d80cd7f hardening(auth): validate + audit EntraId:AdditionalClientIds trust-set expansion
Expanding the token audience trust set via EntraId:AdditionalClientIds was
completely silent: no format validation, no operator-visible logging. During
the tenant-move window a typo'd GUID would be indistinguishable from "the new
app registration doesn't work" (daily-review follow-up, 2026-07-14).

- ResolveConfiguredClientIds: additional entries must parse as GUIDs; malformed
  entries are dropped (fail-closed - they could never match a token aud) and
  reported via out param. Accepted entries are normalized to the canonical
  lowercase dashed GUID form so brace/uppercase paste variants still match the
  ordinal audience comparison. Primary ClientId handling stays verbatim.
- AuditClientIdTrustSet: one Warning per distinct config value per instance for
  (a) dropped malformed entries (truncated - never log a full pasted value in
  case it is a secret) and (b) trust-set expansion with the accepted ID list.
  Warning level because nothing below Warning reaches App Insights.
- Tests: existing ResolveConfiguredClientIds tests moved to GUID values
  (signature change, no compat alias) + rejection, primary-verbatim,
  normalization and TruncateForLog coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:01:43 +02:00
Oliver Kieselbach c02849bd99 added Maintenance Service Announcement 2026-07-14 13:14:31 +02:00
Oliver Kieselbach acc4823c9a section loading design changes 2026-07-13 21:03:37 +02:00
Oliver Kieselbach 9dfbe1e653 fix(web): eliminate Leaflet _leaflet_pos crash on geo-map unmount
The Geographic Performance map threw "Cannot read properties of undefined
(reading '_leaflet_pos')" during client-side navigation. An animated
fitBounds/setView schedules a deferred rAF/transitionend callback that reads
the map pane's _leaflet_pos; if the page unmounts before it fires, the pane is
torn down and the callback crashes.

- FitBounds: pass { animate: false } so the camera move applies synchronously
  and leaves no dangling animation callback.
- GeoMap: memoize mappableLocations so FitBounds' effect (and the camera move)
  only re-runs on real data changes, not on every parent re-render — closing
  the amplified race window.

MapCleanup's map.remove() stays as defense-in-depth for instance teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:01:15 +02:00
Oliver Kieselbach b18846a698 updated gitignore 2026-07-13 18:08:23 +02:00
Oliver Kieselbach 6ff49f7b93 fix(decision-engine): prevent premature device-only completion on WhiteGlove Part 2
Session 62e603c9 (user-driven, Hybrid Join, WhiteGlove Part 2, Classic ESP
with SkipUser=True) completed too early: the device_only_esp_detection 5-min
deadline classified the session SelfDeploying even though the registry probe
said isSelfDeployingProfile=false, Hello timed out and no desktop arrived.
Root cause: the Part-2 reseal-reboot archives the snapshot and re-seeds an
empty scenario profile (Mode=Unknown), so nothing blocked the behavioural
deadline from overriding the deterministic registry fact.

A - carry the Part-1 WhiteGlove classification across the reseal reboot:
RecoveryCoordinator captures rawSnapshot.ScenarioProfile before archiving and
re-seeds Part 2 with Mode=WhiteGlove/High, PreProvisioningSide=User
(reason whiteglove_part2_carryforward). The existing monotonic-mode guards +
deadline race guard C then dead-end the device-only deadline.

B - record the registry self-deploying fact for both true and false and veto
on explicit false: new set-once EnrollmentScenarioObservations
.RegistrySelfDeployingProfile, recorded in HandleEnrollmentFactsObservedV1,
plus a new race guard D in HandleDeviceOnlyEspDetectionDeadlineFired that
dead-ends on an explicit false (device_only_esp_detection_registry_not_self_deploying)
and cancels the deadline state-side. null (unobserved) never trips, so the
legacy behavioural completion and the kiosk case 320b3bf7 (registry true)
are unaffected. Covers non-WhiteGlove SkipUser=True flows too.

Observability - phase_transition events used to emit empty {} DataJson;
BuildPhaseTransitionEffect now attaches a lean typedPayload
{decisionSource, trigger, scenarioMode, scenarioConfidence} at all eight
finalizing/complete call sites, routed verbatim to EnrollmentEvent.Data.

Tests: DecisionCore +5 (guard D false/true/null, false-observation recording,
phase_transition payload), Agent V2 +1 (Part-2 carry-forward). No ConfigVersion
or table-serialization change; snapshot schema stays v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:04:37 +02:00
Oliver Kieselbach bffbf35875 feat(auth): config-driven audience set — trust a second app registration
Make JWT audience validation accept the primary EntraId:ClientId PLUS an
optional comma/semicolon/space-separated EntraId:AdditionalClientIds. Each
client ID expands to {id} and api://{id}; the set is trimmed, empties dropped,
and de-duplicated case-insensitively (fail-closed on empty input).

This is the seam that lets the API trust a second/rotated app registration
during a future Entra app-reg tenant move: adding a client ID to
EntraId__AdditionalClientIds is the only backend step needed. When the setting
is unset the accepted audiences are exactly the primary id — today's behaviour,
zero change for production.

BuildTokenValidationParameters now takes params string?[] clientIds (backward
compatible), with two pure, unit-tested helpers ResolveConfiguredClientIds and
BuildValidAudiences. +12 tests (multi-audience accept, primary still valid,
foreign audience rejected, parsing/dedupe/fail-closed). 64/64 auth tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:44:53 +02:00
Oliver Kieselbach da7d663c3b fix(agent): admin-friendly hello_policy_detection_mismatch wording
"detector bug suspected" confused customer admins and is factually
wrong since sessions 772fe502/1696e416 proved the usual cause: the
user-scoped WHfB CSP value flip-flops during User-ESP while Intune sync
is still writing, so even a twice-confirmed "disabled" read can be
stale by the time the wizard launches.

New message explains what happened and that enrollment tracking is
unaffected (live Hello events take precedence). Adds a likelyCause
data field; event type, severity and detection logic unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:15:03 +02:00
Oliver Kieselbach 108c52be5e updated docs - moved customer graph permission to customer docs 2026-07-13 10:28:44 +02:00
Oliver Kieselbach 0da59a640b updated docs - moved customer graph permission to customer docs 2026-07-13 10:28:32 +02:00
Oliver Kieselbach 977ad37463 fix(agent): HelloWizardStarted signal — never complete while the Hello wizard is running (session 772fe502)
A flip-flopping user-scoped WHfB CSP read "disabled" once, the arm-C
completion synthesized HelloOutcome="Skipped" and the session completed
5s later while the user sat inside the Hello wizard that had started
230ms earlier (Shell-Core 62404, previously InformationalEvent-only).

Layer 1 — engine (DecisionCore):
- New DecisionSignalKind.HelloWizardStarted + set-once fact
  DecisionState.HelloWizardStartedUtc (additive-nullable, census +
  snapshot wired).
- Prevention: centralized HelloSatisfiedForCompletion /
  HelloPolicyDisabledWithoutWizard predicates veto the policy-disabled
  stand-in at all five Skipped-synthesis sites and in the
  completion_waiting prerequisites; the AdvisoryCompletion handler gets
  a HelloWizardObservedPromote branch (its fallthrough is
  re-arm-or-fail).
- Cure: HandleHelloWizardStartedV1 retracts an engine-synthesized
  "Skipped" (exact-case discriminator vs the lowercase tracker
  vocabulary; first fact-nulling reducer, stage-agnostic because the
  RealmJoin gate-defer parks synthetic skips pre-Finalizing), cancels
  finalizing_grace, returns to AwaitingHello and arms HelloSafety.
- Agent path: ShellCoreTracker raises HelloWizardStarted before
  FinalizingSetupPhaseTriggered; 62404 included in the startup backfill
  (fire-once); coordinator timestamp mirror; adapter emit (fire-once).

Layer 2 — HelloTracker confirmation read:
- ApplyPolicyRead state machine: "disabled" commits only after a second
  confirming read (~10s poll); "enabled" commits immediately;
  disabled→enabled flags flipFlopDetected=true; pending keeps the
  policy-unknown wait cadence. Audit fields confirmedReads /
  flipFlopDetected on hello_policy_detected.

Fixtures: signal-kinds/hello-wizard-started-v1.json +
enrollment-sessions/userdriven-hello-wizard-unskip-v1.jsonl (772fe502
ordering, ends Completed with HelloOutcome=completed).
Tests: DecisionCore 462/462, V2.Core 1775/1775.
Docs: OKF concept doc docs/agent/hello-wizard-unskip.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:16:10 +02:00
Oliver Kieselbach b5508965b6 feat(sessions): ReconcileReason — keep backend-declared successes explainable to operators
The Succeeded reconcile hygiene (c0585133) clears FailureReason/FailureSnapshotJson on
every Succeeded write, so a sweep-reconciled session (049b6e5b, session 294ab5b4) was
indistinguishable from an agent-reported completion. Operators must always be able to
tell whether WE declared a status or the device reported it.

- New Sessions column ReconcileReason, set only for backend-automatic Succeeded verdicts:
  the maintenance sweep's classifier reason is persisted verbatim; a reason-less late
  completion upgrading a prior Failed/Incomplete/AwaitingUser verdict synthesizes
  "Late completion report received — upgraded prior '<Status>' verdict".
- Admin flips stay attributed via AdminMarkedAction (no double badge); normal agent
  completions leave the field empty. Pure ComputeReconcileReason next to
  IsTerminalTransitionAllowed, pinned by 17 new facts.
- Survives re-registration (StoreSessionAsync preserve + fresh-terminal-tuple mirror)
  and StartedAt-shift reindex (SessionsIndex projection); force-merge path mirrors the
  normal path against the FRESH prior status.
- Web: "reconciled" chip in SessionStatusBadge (tooltip = justification) + info box on
  the session detail page; dashboard list passes the field through.
- MCP: get_session_summary overview carries reconcileReason.

Backend 2907/2907, web tsc + vitest 557/557, MCP 393 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:40:01 +02:00
Oliver Kieselbach ec5d5661ec docs: use relative links instead of OKF bundle-absolute links
GitHub resolves /-prefixed links against the repo root (missing /docs),
so cross-doc navigation broke. Switched all agent docs to standard
relative links and updated the bundle convention in index.md + CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:48:00 +02:00
Oliver Kieselbach 4ccb3b2317 docs(agent): OKF concept docs for the V2 agent
Three evidence-based documents under docs/agent/: runtime overview
(lifecycle, collector hosts, single-rail telemetry pipeline), decision
engine (DecisionCore reducer, signals/effects, A/B/C completion arms,
invariants, threading), and logs & persistence (artifact inventory,
signal log vs spool rationale, replay-based crash recovery, quarantine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:32:14 +02:00
Oliver Kieselbach 923a1c793a docs: convert /docs into an OKF v0.1 knowledge bundle
Adopt the Open Knowledge Format (GoogleCloudPlatform/knowledge-catalog)
for contributor/AI-facing technical docs: YAML frontmatter on the three
existing concept documents, new index.md (progressive disclosure, root
carries okf_version) and log.md, plus the maintenance convention in
.claude/CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:47:27 +02:00
Oliver Kieselbach 5b808f08de perf(apps): projected app-install scans behind one query core + progress UX for the apps surface
Third slice of the metrics-performance pass (after e63a0bc2 usage, 86f5b507
geo): the app endpoints ran unprojected full-column AppInstallSummaries
window scans behind the web app's default 30s fetch timeout.

Backend:
- All AppInstallSummaries scans (full, geo, app-metrics, apps-dashboard)
  now delegate to one private QueryAppInstallSummariesAsync core so filter
  semantics stay in lockstep
- New projections: AppMetricsProjection for metrics/app + global (ranking
  inputs, failure code and the DO counters) and AppsDashboardProjection for
  the App Dashboard list/analytics/sessions endpoints (drops the whole DO
  telemetry block, which those aggregations never read)
- AppsAnalyticsHelper.LoadSummariesAsync and both app-metrics functions
  switched to the projected variants
- AppsProjectionEquivalenceTests pins both projections, incl. the
  TenantId-from-PartitionKey fallback for legacy rows

Web:
- CalculatingInline: compact in-place variant of the calculating card
  (shared progress bar + elapsed caption extracted)
- Apps installs tab and the app detail page (analytics + sessions fetches)
  use 180s AbortSignals and show inline progress while aggregating

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:43:16 +02:00
Oliver Kieselbach 86f5b50778 perf(geo): parallel projected scans for geographic metrics + shared progress UX
Same pattern as the Platform Usage fix (e63a0bc2): the geographic endpoints
ran two sequential full-column window scans per request while the web app's
default 30s fetch timeout could abort the response client-side.

Backend:
- New column-projected queries: GetGeoWindowSessionsAsync ($select Geo*
  grouping fields + effective-duration inputs) and
  GetGeoAppInstallSummariesAsync ($select session join key, window filter,
  throughput inputs and all Delivery Optimization counters; returned
  AppInstallSummary objects carry only those fields by documented contract)
- All four geographic functions (tenant/global map + tenant/global location
  drilldown) run their scans concurrently via Task.WhenAll; the map
  endpoints use both projections, the drilldowns keep full-row sessions
  (LocationSessionRow returns nearly every column) but switch to the lean
  app scan
- GeoMetricsProjectionEquivalenceTests pins both projections, incl. the
  location grouping key and the DoDownloadMode=-1 "no DO telemetry" marker

Web:
- New shared useFetchProgress hook + CalculatingCard component (elapsed
  counter + progress bar sized from the last recorded fetch duration,
  capped at 95%)
- geographic-performance map + sessions drilldown: 180s AbortSignal and
  the calculating card instead of a bare loading line
- SectionPlatformUsage refactored onto the shared hook/component (drops
  its inline copy of the timer/estimate logic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:28:17 +02:00
Oliver Kieselbach 556707bcd4 fix(metrics): platform stats are monotonic since-release counters — recompute may never regress them
The daily RecomputePlatformStatsAsync overwrote TotalEnrollments,
SuccessfulEnrollments, TotalEventsProcessed, TotalTenants and
UniqueDeviceModels with counts scanned from retention-pruned tables, so
the public "since release" figures regressed after every retention
cleanup. Only TotalUsers already had the high-water clamp.

- extract BuildMonotonicPlatformStats (internal, unit-tested): every
  cumulative counter is Math.Max(recomputed, persisted) — the recompute
  can raise a counter (self-heal for lost fire-and-forget increments
  while sessions are still within retention) but never lower it;
  IssuesDetected stays carried over verbatim (increment-only)
- deliberate exception: TotalSignedUpTenants remains current-state —
  TenantConfiguration is not retention-pruned, a drop there is real
  offboarding, not data loss
- PlatformStats model docs updated to pin the semantics

4 new tests (PlatformStatsMonotonicTests); backend suite 2887/2887.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:12:00 +02:00
Oliver Kieselbach e63a0bc2bd perf(metrics): fix Platform Usage client timeout — parallel projected scans, 15min cache, single-flight, progress UX
Fresh cross-tenant usage computes took 20-43s server-side but the web app's
default 30s fetch timeout aborted them client-side (HTTP 499), and every
retry stacked another concurrent full compute.

Backend:
- UsageMetricsService: cache 5→15min, single-flight per days-window (retries
  join the in-flight compute), the four source scans run concurrently via
  Task.WhenAll, statics converted to instance fields (singleton, testable)
- New column-projected queries for the usage compute only:
  GetUsageWindowSessionsAsync ($select 13 consumed columns, drops
  FailureSnapshotJson & co.) and GetAppInstallRefsAsync ($select
  SessionId+AppName, lean SessionAppRef DTO); GetSessionsByDateRangeAsync
  is untouched for its other consumers
- Projection equivalence pinned by UsageMetricsProjectionEquivalenceTests
  (incl. the WhiteGlove Part-2 duration branch via IsPreProvisioned/
  ResumedAt); single-flight/cache/failure-eviction pinned by
  UsageMetricsServiceSingleFlightTests

Web (SectionPlatformUsage):
- 180s AbortSignal for this call (backend keeps computing and caches)
- Calculating card with elapsed time and a progress bar sized from the last
  fresh-compute duration (localStorage, cache hits excluded, capped at 95%)
- Friendly timeout error explaining the retry will hit the server cache

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:10:56 +02:00
Oliver Kieselbach 860efbbd1b fix(metrics): gate platform TotalEnrollments increment on fresh registration + document totalAllTime in MCP
- IncrementPlatformStatAsync("TotalEnrollments") fired on EVERY
  re-register (agent restarts, WG Part 2 resumes) — the nightly
  recompute corrected it, but the public stat visibly overcounted in
  between. Now gated on isFreshRegistration alongside the per-tenant
  counter (one shared block)
- MCP get_usage_metrics: the tool passes the backend response through,
  so sessions.totalAllTime flows automatically — extend the tool
  description so callers know the field is cumulative since signup and
  retention-independent; bump MCP server to 1.5.1

Backend tests 2876/2876, MCP tsc clean + vitest 393 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:01:14 +02:00
Oliver Kieselbach 778021bc8b feat(metrics): cumulative per-tenant enrollment counter (since signup)
"Total Sessions" on the usage-metrics page is window-bound (90d default)
and capped by retention. Add a retention-independent per-tenant counter
surfaced as "Total Enrollments (since signup)".

- TenantStats row in the PlatformStats table (PK=tenantId, RK="current";
  the platform row's "global" PK cannot collide with a tenant GUID)
- Incremented at session registration, fire-and-forget, fresh
  registrations only (agent restarts / WG Part 2 resumes re-register the
  same session and must not inflate the counter)
- ETag CAS with retries instead of last-writer-wins: lost increments are
  permanent (no recompute can recover pruned sessions), and rollout
  waves register concurrently
- Nightly platform-stats recompute seeds/self-heals the counter to at
  least the live session count (raise-only floor, analog TotalUsers clamp)
- Tenant usage metrics expose SessionMetrics.TotalAllTime =
  max(counter, window total); web page shows a "Total Enrollments" card
  plus clarifying window note ("Total Sessions" is not a lifetime total)

Backend tests 2876/2876, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:52:14 +02:00
Oliver Kieselbach 12e05f2ffb docs: move design docs out of docs/design; keep customer work-files git-ignored
Reorganize docs/ tree:
- docs/design/mcp-oauth-flow.md -> docs/mcp-oauth-flow.md (no customer data)
- enrollment-status-reclassification{,-plan}.md -> tasks/ (git-ignored work
  files; they contained a customer domain that must not ship in the public repo)
- remove now-empty docs/design/ folder

Repoint the 22 source files whose comments referenced
docs/design/enrollment-status-reclassification.md to the new tasks/ path.
Comment/docstring text only; no functional code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:59:31 +02:00
Oliver Kieselbach 6a61ec5360 docs(design): MCP OAuth flow — who authenticates where
Sequence diagram and explanation of the dual-identity connect flow (AI-product
account vs. Entra work account), including the frequently-missed requirement
that the default browser must hold an active vendor session (claude.ai) for
the callback in step 6 to be accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:36:34 +02:00
Oliver Kieselbach c20d9df8dc fix(mcp): pin OAuth redirect allowlist to exact vendor callbacks + token/log hygiene
Medium finding: the anonymous dynamic-client-registration allowlist accepted
any path on whole vendor domains plus wildcard subdomains, so an open redirect
(or controllable subdomain) on an allowed domain could exfiltrate authorization
codes — PKCE does not help when the attacker runs the flow with their own
verifier. Defaults are now the documented callback URIs only (claude.ai +
claude.com /api/mcp/auth_callback, chatgpt.com /connector_platform_oauth_redirect,
vscode.dev + insiders.vscode.dev /redirect); gemini.google.com dropped (Gemini
CLI uses loopback). MCP_ALLOWED_REDIRECT_HOSTS gains optional /path templates
(host-only entries still work). redirect_uris with userinfo or fragments are
rejected outright, loopback included; localhost stays allowed because Claude
Code and Gemini CLI register http://localhost callbacks.

Low finding: /oauth/token now sets Cache-Control: no-store + Pragma: no-cache
on every response; Entra error logging is reduced to status/error/error_codes/
correlation_id; all user-controlled log fields (client_name, redirect_uri) pass
through sanitizeForLog (control-char strip + length cap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:17:09 +02:00
Oliver Kieselbach 9b023ac1d6 fix(web): keep post-part1-complete collector stragglers in WhiteGlove Pre-Provisioning
A periodic collector (e.g. StartupEnvironmentProbes power_state_check) can flush
one last event AFTER whiteglove_part1_complete, still from the draining Part-1
agent. computeWhiteGloveSplitSequence's pre-provisioning-only branch split at the
last cleanup marker, stranding such a straggler into a phantom "User Enrollment /
Resumed" block despite no whiteglove_resumed and no Part-2 agent_started.

Restore the invariant: no Part-2 boot evidence => no User Enrollment part. That
branch now splits at the highest sequence in the session so every trailing
straggler stays in Pre-Provisioning; the session renders the accurate "Awaiting
User Enrollment" placeholder instead. Adds a regression test from field session
1293f80e. Existing case-3 tests unaffected (marker was already the max sequence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:12:45 +02:00
Oliver Kieselbach 611db9261c fix(agent): defer ImeUserSessionCompleted while required user-ESP installs are pending
IME writes its "[Win32App] .... Completed user session N ...." line at the
end of EVERY user-session processing pass, not when user-scope enforcement
is finished. Field sessions 14690fc2 and 6cb01530 (2026-07-10, WhiteGlove
Part 2 + mid-AccountSetup reboot) matched the line on the first post-reboot
pass with 13-18 required apps still pending; arm C of
ShouldTransitionToAwaitingHello took the signal as its fourth fact and the
session completed Succeeded five minutes later (HelloSafety timeout ->
FinalizingGrace) mid-install, spraying app_install_starved events.

Harden at the signal source: ImeLogTrackerAdapter.EmitUserSessionCompleted
now consults the new ImeLogTracker.GetPendingRequiredUserEspInstallApps()
probe (AccountSetup phase, Install intent, not terminal) and defers the
decision signal while the list is non-empty. The fire-once flag stays
unclaimed, so the completion line of a later pass re-evaluates and posts
once the apps have settled. Error counts as terminal (GRS owns retries) so
a permanently failed app cannot park the signal - and with it the
AdvisoryCompletion conjunction - into a 30-min-backstop false Failed.
Non-AccountSetup completions post as before (pre-anchor stamps stay
harmless via IsImeUserSessionGenuine).

Observability for the field watch: INFO log line per suppressed pass plus
a one-shot agent_trace (decision=ime_user_session_completion_deferred,
pending count + capped names) on the session timeline.

Session 04e8d52e

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:17:45 +02:00
Oliver Kieselbach fa03e290b2 feat(agent): os_build_changed detection + WU channel blind-spot census (session 7443317c)
Session 7443317c proved a WU-tracker blind spot: an OOBE quality update
installed (OsBuild 26200.8037 -> 26200.8655 across the mid-enrollment reboot)
yet the deployed WindowsUpdateTracker emitted ZERO events - the watcher armed
fine (no collector_degradation), the spool is durable, and two 60-min backfills
covered the install window, so the WindowsUpdateClient channel simply carried
none of the targeted EventIDs 19/20/43/44. The tenant has diagnostics upload
disabled, so the fix makes the feature self-evidencing instead of guessing IDs:

- OsBuildChangeHost: persists CurrentBuild.UBR (Registry64 view) to
  AgentState/os-build-state.json and emits os_build_changed (Info, immediate)
  when it differs across a restart - deterministic update corroboration that
  cannot be lost to timing, watermarks, or unexpected servicing paths. First
  run seeds silently; corrupt state re-seeds.
- WindowsUpdateTracker channel census: when the build provably changed AND zero
  targeted WU events were emitted this run, emit a one-shot
  windows_update_channel_census (Debug) with an unfiltered EventID histogram of
  the WindowsUpdateClient AND UpdateOrchestrator channels over the lookback
  window (capped at 5000 records, cap surfaced as censusTruncated). The
  histogram arrives via normal event upload and tells us which IDs to add to
  WindowsUpdateTargetedEventIds via remote config - no redeploy, no diag ZIP.
  Gated by CollectorConfiguration.WindowsUpdateChannelCensusEnabled (default
  on, model-default pattern, no ConfigVersion bump).
- No new hardcoded EventIDs (per the device-validation rule) - census data
  decides what to enable remotely.
- ANALYZE-DEV-006 "OS Build Changed During Enrollment" (info, not KO):
  interpolates {{previousBuild}}/{{currentBuild}}, counts the census as
  corroboration. Catalogs: Constants.cs + MCP resource-catalog.ts + web
  eventTypes.ts (drift-test enforced).

Tests: 6 OsBuildChangeHost (seed/same/changed/unreadable/corrupt/no-dir),
7 census (gating matrix, truncation surfaced, scanner-throw swallowed,
histogram format), 3 ANALYZE-DEV-006. Agent.V2.Core 1754/1754, Backend
2874/2874, MCP 379 (drift green), web tsc + vitest 367/367, sln 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:42:48 +02:00
Oliver Kieselbach 76993478a5 fix(decision-core): rebase esp-exit advisory window on reboot instead of cancelling (session 7443317c)
The 9a107a19 reboot-cancel guard correctly stopped false
esp_exit_without_completion_evidence failures, but a pure cancel left the
post-AccountSetup dead-end zone with no resolution-capable deadline: session
7443317c (same tenant/shape as 1924092e, plus a mid-OOBE Windows-Update reboot
chain) tripped the session_parked_without_deadline liveness tripwire on a live
enrollment that later completed normally.

RebaseEspExitVariantAdvisoryWindowOnReboot now replaces the stale window with a
fresh 30-min one whose enforcement baselines (armSignalOrdinal /
armAppTerminalCount) anchor at the reboot signal, from both
HandleSystemRebootObservedV1 and HandleEspResumedV1:

- A live enrollment keeps disproving the dead-end at each fire via
  HasEnforcementProgressSinceArming (re-arm, never fail).
- A true dead-end where the user rebooted inside the window now resolves one
  window later (30-60 min) instead of idling to the 6-h max-lifetime watchdog -
  the 9a107a19 trade-off gap is closed.
- The parked tripwire needs no change: a resolution-capable deadline is armed
  throughout, so IsParkedWithoutDeadline stays false and the tripwire keeps its
  "every fire is a bug" charter.
- The rebase emits a completion_waiting with the new resolutionDeadlineDueAtUtc
  (trigger <Signal>:AdvisoryRebase), deliberately bypassing the fingerprint
  dedupe like the EnforcementActive re-arm site - bounded to one per reboot.

Tests: rebase with reboot-anchored baselines (both call sites), advisory
variant exempt, no-window no-op, true-dead-end-after-reboot fails at the
rebased due-time, 7443317c double-reboot live-install replay, fire-once keeps
the rebased window, 1924092e full replay updated (stray fire re-arms on
progress), engine-driven parked-tripwire regression.
DecisionCore 445/445, Agent.V2.Core 1754/1754.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:42:23 +02:00
Oliver Kieselbach c6b6e4504a feat(decision-core): proactive completion arm C from 4-fact post-ESP user-session evidence
Session a4537c36: Windows closed the User-ESP page normally (Shell-Core 62407,
errorCode=0) while the AccountSetup registry never wrote categorySucceeded -
the strong gate (arm A) was unsatisfiable by construction and the session only
completed because the Hello policy happened to flip to enabled after a reboot.

ShouldTransitionToAwaitingHello gains arm C: AccountSetup entered + normal ESP
final exit (post-AccountSetup by INGEST ordinal - backdated CMTrace timestamps
are irrelevant, sequence is canonical) + genuine IME user-session completion
(defaultuser0 guard: at-or-after the AccountSetup anchor, extracted as shared
IsImeUserSessionGenuine and reused by the AdvisoryCompletion backstop) + DAD-
validated real-user desktop. ALL FOUR mandatory; Hello gating stays at the
call sites - arm C never bypasses an enabled-unresolved Hello.

Wiring per signal ordering: desktop last via the fast-path (desktopArrived-
InFlight), exit last via HandleEspExitingV1 (espFinalExitInFlight + deferred-
completion parity mirroring the caa6cf50 branch), IME last via new completion/
promote wiring in HandleImeUserSessionCompletedV1. completion_waiting stops
reporting account_setup_provisioning_complete once the evidence set holds.
AdvisoryCompletion stays as the 30-min backstop for incomplete evidence.

The 08c99638 regression guard (3-of-4, no IME fact -> parked) stays green
unmodified; the L5 backdated-exit test now asserts the eager completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:05:00 +02:00
Oliver Kieselbach e27c7b4298 feat(agent): surface ESP tracking-registry app lists on esp_config_detected
Session a4537c36: the agent could not tell ESP-blocking apps from merely-required
IME assignments. New EspTrackingInfoProbe reads the ESP''s own tracking lists from
HKLM\...\Autopilot\EnrollmentStatusTracking\ESPTrackingInfo\Diagnostics (device
scope + per-SID user scope; categories ExpectedMSIAppPackages / Expected-
ModernAppPackages / Sidecar, deduped across timestamped status-write subkeys)
and attaches them to esp_config_detected: espTrackedWin32AppIds (+ user-scoped
subset), espTrackedMsiProductCodes, espTrackedModernAppPfns, plus uncapped
counts (lists capped at 50). Registry semantics per Get-AutopilotDiagnostics.ps1.

Sidecar GUIDs are normalized to lowercase dashed form so they string-match
AppPackageState.Id - prepared for the espBlocking annotation follow-up on
app_install_starved (deliberately not wired yet; collect field data first).
Fail-soft like EspSkipConfigurationProbe; keys fully omitted when the
Diagnostics key is absent. Decision-signal payload untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:48:04 +02:00
Oliver Kieselbach c6cdae5228 fix(agent): starved-app detection Install-intent only, soften to Info on Succeeded termination
Session a4537c36 debug: 4 of 6 app_install_starved warnings were Uninstall-intent
system-app removals (Xbox, WMP, Ubuntu) - the ESP apps gate never blocks on
uninstalls and "never started installing" is meaningless for them. Filter at the
single chokepoint ImeLogTracker.GetStarvedUserEspApps so both emission paths
(live esp_exited + termination sweep) are covered.

The termination sweep also claimed "the app starved the enrollment completion"
on terminationOutcome=Succeeded - contradictory on a successful session. Now
Info severity + neutral wording (installs continue via IME post-ESP); Failed/
TimedOut keep the Warning verdict. Data fields unchanged.

AreUserEspAppsSettled deliberately untouched (completion behaviour stays
conservative until field data proves the ESP never waits on uninstalls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:40:05 +02:00
Oliver Kieselbach 44e3efdcad updated commands 2026-07-10 15:53:32 +02:00
Oliver Kieselbach 9a107a1929 fix(decision-core): stop failing live enrollments via the esp-exit advisory window (session 1924092e)
The 1ec8f4c6 dead-end window (guard-blocked post-AccountSetup esp_exiting arms a
30-min advisory_completion resolution deadline) misfired on the Device->Account
handoff exit whenever the IME logged an AccountSetup phase line pre-sign-in
(defaultuser0/AutoLogon frame). The recovered deadline then failed sessions
mid-AccountSetup while apps were actively installing:
enrollment_failed: esp_exit_without_completion_evidence on a live enrollment.
41 of the 65 affected sessions since 2026-06-15 show reboots or app activity
after the arming exit - false positives.

Two guards, esp-exit variant only (the advisory variant keeps its real-failure
un-defang semantics):

- Reboot cancel: SystemRebootObserved / EspResumed cancel the armed window -
  an arming exit that predates a reboot was a pre-reboot page close, not the
  "page closed, nothing happens" dead-end. A later genuine guard-blocked exit
  re-arms a fresh window.
- Enforcement-progress re-arm: arming baselines (armSignalOrdinal,
  armAppTerminalCount) ride on the deadline's FiresPayload (replay-deterministic,
  snapshot-round-trips, no DecisionState schema change). At fire time, apps
  reaching terminal states or an ESP user-phase re-assert since arming re-arms
  the window instead of failing. Convergent: each re-arm demands new progress;
  legacy deadlines without baselines resolve with pre-fix semantics.

11 new tests incl. a full session-1924092e replay (handoff exit -> reboot ->
active installs -> stray recovered-timer fire dead-ends -> Completed) and pins
for the true dead-end verdict, the advisory variant, and baseline serialization.
DecisionCore 435/435, Agent.V2.Core 1725/1725.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:40:16 +02:00
Oliver Kieselbach cc3d23efe3 added tip of the day for report session 2026-07-10 15:26:37 +02:00
Oliver Kieselbach f1d00fec0f fix(web): dedupe rule-result evidence chips by eventId
Multiple matched conditions often extract different fields from the same
event (e.g. enrollment_failed + enrollment_failed_reason), which rendered
duplicate identically-labeled chips all jumping to the same timeline event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:59:22 +02:00
Oliver Kieselbach 5b7a602574 fix bandwidth estimation event sequene ordering 2026-07-10 12:51:56 +02:00
Oliver Kieselbach 0cba2e5611 feat(mcp): expose list_tenants to delegated (MSP) admins for display-name resolution
A delegated caller previously had no way to resolve its managed tenants
GUIDs to display names - the tool catalog registered list_tenants only
for platform scope (GA/Reader), and the server instructions listed the
managed set as bare GUIDs. "Which tenants can I access?" answered with
GUIDs, and "search sessions of tenant contoso.com" had no resolution
path, although the backend (config/all, GlobalReadOrDelegatedSubset)
already serves the bounded, secret-redacted managed subset.

- register list_tenants for delegated callers with role-aware wording;
  backend bounds the response, delegatedTenantListView re-intersects as
  defense-in-depth (web analog: delegatedScopedTenantList)
- surface the caller's home tenant (isHome: true), synthesized on the
  first page with the UPN-domain label when not in the managed subset
- carry the caller UPN through CallerContext for that label
- point tenantIdDescription, enforceDelegatedTenant errors and the
  delegated instructions line at list_tenants for name resolution
- bump MCP server to 1.5.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:18:51 +02:00
Oliver Kieselbach cc96b5f074 fix(web): restore home-tenant access for delegated (MSP) admins
Web analog of the MCP fix (88a432dc): a delegated admin who is also a
member/operator of their own home tenant could not see or read it in the
web app — every delegated surface bound the tenant list strictly to
delegatedTenantIds (which never contains the home tenant) and routed all
reads over /global/* endpoints, whose delegated fan-out is bounded to
the managed set and would return empty for the home tenant.

Fix is web-only; the backend already authorizes home-tenant reads on the
JWT-bound member path (MemberRead) and stays authoritative:

- utils/homeTenantScope.ts: isHomeTenantTarget, upnDomain, and
  delegatedScopedTenantList — managed subset plus a synthesized home
  entry (UPN-suffix domain, isHome flag) when the caller has a home
  member role (user.role). Returns empty while config/all is still
  loading so the render-time seeding cannot discard a persisted managed
  selection in favor of an early home-only list.
- useGlobalAdminScope / useAggregatedAdminScope: home tenant in the
  selector list; new routeGlobal flag (= isGlobalAdmin EXCEPT
  delegated+home -> member path), mirroring MCP pickGlobalOrTenantPath.
- Pages switched from isGlobalAdmin to routeGlobal for endpoint choice:
  analyze-rules (rule stats), audit, fleet-health (hook param renamed,
  also drives SignalR scope), apps tabs (installs/inventory/vulns),
  geographic-performance (+ sessions drill via homeDrill carve-out).
  isGlobalOverride-keyed pages (gather-rules, analyze-rules list,
  usage-metrics, sla) and apps/[appName] were already correct.
- Dashboard: home tenant in the filter autocomplete; session list and
  stats route a home-tenant filter to the member endpoints before the
  delegated bound (boundTenantToDelegatedScope itself stays strict).
- Fleet: home-tenant card ("your tenant" badge); useFleetSummaries
  fetches the home summary via the member stats endpoint.
- TenantInfo.isHome + selector label "— your tenant".

Verify: tsc clean; vitest 556 passed (+13 homeTenantScope cases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:01:36 +02:00
Oliver Kieselbach 170e66133d fix(backend): honor Hello-disabled + SkipUserEsp in the user-completed-setup reconcile rule
Follow-up to 049b6e5b: tenants running with Windows Hello disabled AND
the User ESP skipped can never produce a Hello terminal event, so the
reconcile rule would never fire for them and silent post-desktop
sessions stayed mislabeled AwaitingUser -> Incomplete. Mirror the
agent's Hello-disabled fast-path (HandleDesktopArrivedV1: policy
disabled + SkipUserEsp + desktop => complete):

- ExtractRollup reads hello_policy_detected Data.helloEnabled and
  esp_config_detected Data.skipUserStatusPage (contradicting
  observations resolve pessimistically to enabled/required).
- Rule 4 accepts desktop + (Hello terminal OR policy-disabled +
  SkipUserEsp). Hello disabled WITHOUT SkipUserEsp keeps waiting —
  the agent's strong post-AccountSetup gate (session 08c99638) blocks
  its fast-path there too, and rule 2 covers the rollup-complete case.
- esp_exiting stays rejected as success evidence (page-lifecycle event,
  fires with errorCode=0 while apps are unfinished — lesson 08c99638).
- Snapshot v3 + Web block additionally carry skipUserEsp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:51:57 +02:00
Oliver Kieselbach 049b6e5b4a fix(backend): reconcile silent sessions to Succeeded when user provably finished setup (session 294ab5b4)
The timeout classifier only read the ESP registry rollup, so a session
where the user reached the desktop and completed Windows Hello — with
only the RealmJoin completion gate still pending when the agent went
silent — was labeled AwaitingUser (and would have graduated to
Incomplete at grace expiry). Desktop arrival + a positive Hello terminal
are the agent's own Classic completion AND-gate; the RJ gate
self-releases after 60 min, so such a session's enrollment succeeded and
only the completion report never left the device.

- EnrollmentTimeoutClassifier: rollup now carries DesktopArrived,
  HelloResolved (hello_provisioning_completed/hello_skipped only) and
  RealmJoinDetected/Resolved (realmjoin_resolved/realmjoin_timeout);
  new rule 4 reconciles desktop+Hello sessions to Succeeded with an
  RJ-aware reason. Explicit failure and agent_emergency_break still win.
- FailureSnapshotBuilder: schema v3 embeds the new evidence
  (helloResolved, realmJoinDetected, realmJoinResolved).
- Web FailureSnapshotBlock: renders Hello completed + RealmJoin rows.
- Design doc: decision table updated; desktop_arrived ALONE stays
  rejected as a completion signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:26:37 +02:00
Oliver Kieselbach a0238a4b30 fix(agent): route runtime self-update restart through graceful shutdown (B1)
Follow-up hardening to 57e4334b (session b9b92d89). The runtime hash-mismatch
trigger runs the update pipeline on a thread-pool task concurrent with the live
orchestrator; RestartAgent then called Environment.Exit directly, leaving the
ProcessExit handlers only the CLR's ~2-3s window — far too short for the full
drain (ingress -> orchestrator.Stop -> spool flush -> snapshot). That kill
window is what produced the duplicated SignalLog line in the field.

Coordination via static hook (same idiom as BackendExpectedSha256):
- AgentRuntimeHost wires SelfUpdater.RequestGracefulShutdown BEFORE
  orchestrator.Start (no window where the SignalLog is open but a restart
  would hard-exit) and clears it in the shutdown finally
- Hook emits agent_shutting_down(reason=self_update_restart) through the
  cross-path idempotency gate and signals the host loop -> normal
  TerminationPipeline drain, process exits on its own
- RestartAgent: graceful path waits bounded (15s hard-exit fallback, well
  under the restart script's 30s Wait-Process cap so the new agent never
  races the old one into the multi-instance guard); startup-phase updates
  (hook unset, no orchestrator) keep the original immediate exit
- Hook invocation is fail-safe: a race with the host's teardown (disposed
  shutdown event) degrades to the immediate-exit fallback, which is safe
  again once the orchestrator has stopped

Suites green: Agent.V2.Core 1724 (5 new hook-contract tests), DecisionCore 423.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:39:41 +02:00
Oliver Kieselbach 57e4334bed fix(agent): recovery crash-loop on duplicated SignalLog line (session b9b92d89)
A runtime self-update restart (runtime_hash_mismatch, force) killed the agent
mid-append: the final agent_shutting_down line landed byte-identically twice
(FileStream 4096-buffer re-flushed by Dispose after a faulted flush). The next
start's ReducerReplay threw non-monotonic ordinal (27 <= 27) and the uncaught
exception turned one crash artifact into a PERMANENT startup crash-loop.

Three surgical fixes:
- SignalLogWriter.Append: bufferSize 1 -> unbuffered write; Dispose can never
  re-flush the payload (at-most-once physical append)
- SignalLogWriter.ReadAll: skip byte-identical consecutive duplicate lines
  (crash artifact, not tamper); non-identical duplicate ordinals stay visible
- RecoveryCoordinator.Recover: catch InvalidOperationException from replay ->
  quarantine all reducer segments + snapshot, reseed from Initial state
  (mirrors the existing log-head-corrupt path) instead of fatal exit

E2E-verified against the real customer signal-log.jsonl: 29 lines -> 28
distinct signals -> replay OK (LastAppliedOrdinal=27, zero signal loss).
Suites green: Agent.V2.Core 1719, DecisionCore 423.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:22:51 +02:00
Oliver Kieselbach 7afa03aaf1 fix(agent+web): honest script durations — runtime vs IME reporting latency, live counter for platform scripts
Remediation rows showed "8m" for scripts that ran seconds: durationSeconds was
measured HS-SCRIPT-START -> HS-NEW-RESULT, and IME writes that line only after
its batched service report. The early-signal (HS-COMPLIANCE) completion carried
no duration at all because it never read the cycle-start map.

Agent:
- HS-COMPLIANCE early-signal completions now PEEK _healthScriptStartTimes and
  emit the actual run time (durationBasis=script_runtime); HS-NEW-RESULT keeps
  consuming the entry and stamps its phases cycle_including_reporting_latency.
- durationBasis now comes from the emitting handler (ScriptExecutionState.
  DurationBasis) instead of pauschal per scriptType.
- Platform scripts emit the live script_started signal too (gated on pending-
  slot creation so the duplicate agentexecutor+ime start line stays one signal).

Web:
- Reducer normalizes durationBasis: runtime -> durationSeconds, cycle ->
  reportedAfterSeconds (legacy events without basis infer from scriptType, so
  old remediation cycle values are never displayed as run time again).
- Dedupe merges both timings across the early/late emissions instead of
  dropping the runtime when the more complete HS-NEW-RESULT entry wins.
- Card run time = max of phase runtimes; "Reported after Xm" shown in the
  expanded Details strip (per UX decision), never on the compact row.

Inert for new events until agent redeploy; web deploy alone already fixes the
misleading display of stored data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:20:12 +02:00