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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>