Two security-critical paths had zero unit coverage because the logic was inlined
behind infrastructure (a real X509Store / a live OIDC ConfigurationManager +
HttpContext). Extract the decision logic into pure, testable seams — no
behavioural change — and pin it.
CertificateHelper (Agent):
- Extract SelectMdmCertificate(IEnumerable<X509Certificate2>): the issuer
discrimination (CN=Microsoft Intune MDM Device CA only, never the MMP-C CA),
ClientAuth-EKU filter and NotAfter tiebreaker. FindMdmCertificate now calls it.
- 13 tests incl. the dual-stack regression (issuer filter beats the NotAfter
tiebreaker so a longer-lived MMP-C cert can't win over a genuine MDM cert).
AuthenticationMiddleware (Backend):
- Extract BuildTokenValidationParameters(signingKeys, clientId), IsValidTenantId(tid)
and BuildTenantAuthority(issuer, tid). Invoke() now composes them.
- 16 tests validating locally-minted tokens against a test key: alg:none and HS256
rejected (algorithm-confusion defence), expired / wrong-audience / foreign-issuer
/ unknown-signing-key rejected, valid RS256 + api:// audience + v1 issuer accepted,
plus the tid GUID gate and v1/v2 authority mapping.
Backend 2746/2746 and Agent 1615/1615 green; full solution builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six existing test classes had clusters of near-identical [Fact] methods that
differed only by input/expected literals. Convert each cluster to a single
[Theory], preserving every case (and its explanatory comment) as an InlineData/
MemberData row. Pure refactor — no behavioural change, no coverage lost.
- DeviceInfoProviderVmDetectionTests: 18 Facts -> 1 Theory (IsVirtualMachine)
- RateLimitResolverTests: 12 Facts -> 2 Theories (device + user resolve)
- SecurityValidatorTests: IsValidGuid cluster (10 Facts) -> 1 Theory
(WithNewGuid stays a Fact — dynamic input; OData-injection Theory untouched)
- CpeUriNormalizerTests: 8 Facts -> 1 Theory (Normalize)
- SignalRGroupHelperTests: ExtractTenantId cluster (6 Facts) -> 1 Theory
(MemberData; group names interpolate the const GUIDs. Access-gate Facts kept)
- VersionComparerTests: IsVersionAffected cluster (6 Facts) -> 1 Theory
Backend 2730/2730 and Agent 1607/1607 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The malformed-input theory asserted a BOM-prefixed CMTrace line yields false,
assuming a leading U+FEFF breaks the StartsWith gate. It doesn't: culture-
sensitive string.StartsWith treats U+FEFF as ignorable so the gate passes, and
the unanchored regex matches the "<![LOG[..." body after the BOM — the line
parses (which is desirable; Windows writes a BOM on freshly-opened log lines).
Move the BOM case out of the malformed theory into a dedicated fact that pins
the BOM-tolerant behaviour. Product code unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A delegated admin who is also a member/operator of their own home tenant
lost MCP access to it: any non-empty delegated scope flipped the whole
session to delegated cross-tenant routing, and enforceDelegatedTenant
rejected every tenant outside the managed set — which never contains the
home tenant (GetScopeAsync uses the home tid only as the Enterprise
entitlement gate, never adds it to the scope).
Fix is MCP-only (no backend change; the backend already authorizes the
home tenant via MemberRead and bounds the /api/global/* drill away from
it, so the home tenant must route to the JWT-bound member path):
- access-guard.ts: thread homeTenantId (JWT tid, lowercased) into the
caller context.
- client.ts: getHomeTenantId(); enforceDelegatedTenant accepts
managed union {home}; enforceDelegatedTenantForPage resolves a
non-global tenant-path nextLink (no embedded tenantId) to home so
page-2 re-selects the member path; pickGlobalOrTenantPath gains an
effectiveTenantId arg and routes delegated+home to the tenant path.
- 16 pattern-A tool call sites pass the effective tenantId (not
devices/blocked, which is platform-only).
- shared.ts / index.ts: descriptions + instructions surface the home
tenant as an addressable target.
The home tenant is accepted optimistically at the MCP boundary; the
backend MemberRead stays authoritative (a non-member is denied on the
member path, so no leak). Continuation defense-in-depth preserved.
Verify: tsc clean; vitest 352 passed (+7 home-tenant cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex follow-up: the previous middleware comment claimed a deterministic limit+1 block, but McpQuotaService caches the per-user decision for 60s - an allowed decision keeps admitting inside that window even after the async increments cross the limit. The soft boundary IS the intended design (bounded overshoot ~TTL x request rate per instance, same posture as the sliding-window rate limiter; one counter read per user per minute instead of per request).
- Middleware doc rewritten: check-then-increment fixes the race/inflation, but the boundary is explicitly documented as SOFT with bounded overshoot; warns against re-documenting as exact without reworking the decision cache.
- Two regression tests pin the contract: cached allowed decision keeps admitting within the TTL even after counters cross the limit (no storage re-read), and after cache expiry (simulated via eviction) the over-limit counters block.
Backend 2658/2658 green.
- High: TenantConfigurationService.SaveConfigurationAsync now THROWS when the repository reports a failed persist (it swallows storage exceptions and returns false) - plan/trial/config writes can no longer be audited and returned as 200 without being persisted. Cache is invalidated on every attempted save (finally), covering in-place mutations of the cached instance; the now-redundant invalidate-on-failure wrapper in PlanManagementFunction is removed. AuthFunction login side-effect saves (first-login domain write, auto-re-enable) are wrapped best-effort so a storage blip cannot 500 auth/me - they self-gate and retry on next login.
- Medium: MCP usage increment moved from AuthenticationMiddleware (fire-and-forget BEFORE the quota decision) into McpQuotaEnforcementMiddleware: strictly check-then-increment and only for requests actually served (GA tracked but quota-exempt; denied 403/429 requests no longer inflate counters; the exact-limit request is now deterministic - 1..limit pass, limit+1 blocks).
- Low: fixed both new nullable warnings (PlanTierDefinitionParser guarded json, McpQuotaEnforcementMiddleware principal null-flow).
Tests: +3 save-semantics regression tests (throw on false, cache invalidation on failed/successful save). Backend 2656/2656 green, build clean.
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).
- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.
Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
Replace the denormalized per-tenant RateLimitRequestsPerMinute mirror (and its
SaveConfigurationAsync sync job) with compute-at-read resolution: effective limit =
per-tenant override ?? global default. Fixes the clobbering bug where a per-tenant
edit to the base field was overwritten on the next global config save.
- Model: drop TenantConfiguration.RateLimitRequestsPerMinute; add nullable
CustomUserRateLimitRequestsPerMinute (device override CustomRateLimit... kept).
- RateLimitResolver centralizes precedence (device + user); both paths call it.
- SecurityValidator reads the global default (AdminConfigurationService threaded
through ValidateSecurityAsync + all device-auth callers); 5-min cached.
- UserRateLimitMiddleware: per-tenant user override for tenant-scoped callers only
(skipped for HasGlobalScope = GlobalAdmin|GlobalReader); GA -> GA budget, others ->
user default. Uses GetConfigurationIfExistsAsync so an unregistered-tenant JWT can
no longer auto-create a config row via read endpoints.
- Validation: reject rate limits < 1 at the write functions; RateLimitService clamps
< 1 to 1 as a safety net (avoids Min()-on-empty / throttle-everything).
- Web: Global Settings exposes User + GlobalAdmin limits; tenant modal writes the
override fields (blank = inherit); report + usage-plans updated.
Legacy RateLimitRequestsPerMinute Table column is ignored on read and dropped on the
next Replace save (self-cleaning; no migration).
Backend 2577 xUnit green, tsc clean, web 506 vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The detailed per-component chapters (Agent internals through Build & Development)
are retired — product documentation lives at docs.autopilotmonitor.com now, and
the deep internals went stale faster than they earned their keep. What remains
is corrected against the actual tree: Shared/DecisionCore and SummaryDialog.Tests
added, src/McpServer and rules/guardrails.json were missing entirely, scripts/
gained Backup + CustomerSetup, Functions references DecisionCore too, and the
overview diagram no longer claims the web app serves docs.
docs/images stays — all five files are embedded in the repo README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- msalConfig: NEXT_PUBLIC_ENTRA_API_CLIENT_ID splits the API scope from
the SPA client id, so local dev signs in with the dev app registration
while requesting the prod API audience. Unset in prod (fallback keeps
the single-app behavior).
- next.config: dev-only rewrites() proxy forwards /api/* server-side to
DEV_API_PROXY_TARGET, keeping the browser same-origin — no localhost
CORS entry on the prod Function App.
- CSP: script-src gains unsafe-eval only when NODE_ENV=development
(next dev HMR/react-refresh needs eval); production CSP unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the favicon (app/icon.svg) with the new green glyph and swap the
portal navbar logo from the blue gradient box to the bare green icon with
the wordmark in sidebar gray (text-gray-600 / dark:text-gray-400). Both
portal navbar variants updated; the public landing navbar keeps the old
gradient branding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route, nav/footer/about links, sitemap and robots entries removed; /roadmap
temporarily redirects to / (non-permanent — a roadmap may return under the
docs site later). Verified: tsc clean, 506 tests green, production build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove app/docs (15 sections incl. agent-internals, sidebar, gates, registry)
and app/changelog; drop the now-unused private-preview.png
- Permanent redirects in next.config.ts: per-section /docs/* mapping to the
matching GitBook page, /docs catch-all, /changelog -> platform changelog
- All navigation and deep links point at the GitBook site (public navbar,
authenticated navbar help menu + overflow, global sidebar, landing footer,
about cards, preview page, dashboard banner, WelcomeMessage, analyze-rules
and gather-rules helper links) — external links open in a new tab
- SEO: sitemap no longer emits /docs/* or /changelog, robots allow-list
trimmed; old URLs 308-redirect so link equity moves to the docs domain
Verified: tsc clean, 506 web tests green, production build succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match the GitBook docs site branding: the visible brand name is now
"AutopilotMonitor" (no whitespace) and browser-tab titles use
"Page | AutopilotMonitor" instead of an en-dash. Tagline titles keep
the en-dash; descriptions and SEO keywords keep the spaced spelling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Retire ANALYZE-ESP-003 (duplicate of ESP-001) and ANALYZE-CORR-002 (duplicate
of APP-004) via the established sunset path; ESP-001 gains a >60min factor,
APP-004 absorbs the co-occurrence story with working confidence factors
- CORR-003: download evidence (errorPatternId) is now a required condition —
no more firing on proxy + any unrelated app failure
- DEV-002 v2: 'sustained' semantics — new event_count value filter
(filterField/filterOperator/filterValue in schema, Shared model, engine, web
types) requires >=3 snapshots above 90% memory instead of one transient spike
- DEV-003 v2: build-cutoff-only condition (<=22621) now also covers Windows 10;
LTSC caveat + maintenance note (raise to 22631 after 2026-11-10)
- SEC-001 v2: rewritten for the post-June-2026 reality — certificates HAVE
expired, active remediation required (OEM firmware hint added)
- SEC-005/006 v2.1: allow-list extended with OEM factory-preload families
(Intel PPM, AMD Power, Lenovo ThinkPad power/battery, Surface/carrier),
telemetry-curated from 1780 fired sessions; imaging leftovers (USMT.PPKG,
InstallOrder.ini) and generic names deliberately still alert
- APP-001: suppressByEvent on app_install_completed; APP-002: exact MSI exit
codes instead of bare 16xx; APP-006 reframed as 'App Install Retry Loop';
ID-001: title/logic mismatch fixed + GATHER-ID-002 dependency documented
- Fix all 13 dead confidence factors (signal-name mismatches / unsupported
syntax) by rewiring or removal; version bumps everywhere so seeding
propagates the updates
- New RuleEngineEventCountFilterTests + SEC-005 OEM allow-list coverage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Revoke enforcement (L4): a revoked delegated (MSP) user kept receiving pushes on
already-joined SignalR groups until the connection dropped (join-time-only
authorization). Negotiate now goes through the Management SDK and binds the
client token to a SignalR user id (lowercased UPN); new DisconnectUserAsync
strips the user from all groups server-side and pushes "accessRevoked", on
which the web client restarts its connection so every re-join re-runs
authorization against the fresh scope. Wired into delegated revoke/disable and
tenant-group unassign / remove-tenant / delete-group. Residual (documented): a
tampered client ignoring the push, and connections negotiated before this
deploy (no user id), keep streams until the connection drops.
Revoke audit integrity (L5): POST /global/delegated-admins now requires a GUID
tenantId (mirrors the tenant-group path), and DELETE reports 404 for an
assignment that never existed instead of returning 200 and writing a false
customer-visible "access removed" audit row (repo delete now checks the 404
response of the idempotent DeleteEntityAsync).
Casing (L6): the SignalR group join/leave cross-tenant check now compares
tenant IDs OrdinalIgnoreCase, aligned with the middleware and the delegated
scope dictionary, so a casing difference can never flip which branch
authorizes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M1: hoist the McpUsers Disabled-row kill-switch above the policy switch so an
explicitly disabled account is denied MCP access under EVERY policy and grant
path (AllMembers, platform roles, delegated auto-grant) — previously inert
outside the WhitelistOnly branch.
M2: drop the MCP access-guard verdict cache from 5 min to 60s. The verdict
carries the kill-switch and the delegated tenant set; 5 min stretched the
backend's 30s revocation target to minutes for MCP callers.
M3: gate same-tenant joins of the plain tenant-{tid} SignalR broadcast group
on a member role (new pure SignalRGroupHelper.IsTenantBroadcastJoinDenied) —
roleless authenticated users (Progress Portal) could passively stream
MemberRead-tier live telemetry they cannot fetch over REST. Companions:
MarkSessionSucceeded/Failed additionally push "newevents" to the per-session
group so a roleless progress watcher still sees admin-marked completion, and
the Progress Portal no longer joins the tenant group (session group covers
all its signals).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kill/Block rules were only served on the telemetry response, which cannot
reach an agent whose upload loop is paused (indefinite block pins the drain
until DateTime.MaxValue), whose spool is empty, or whose binary is kill-blind.
Config is fetched at every process start (boot via Scheduled Task), making it
the reliable control channel - no heartbeat added by design (48h emergency
break + 6h max lifetime bound the remaining cases).
Backend (effective on deploy):
- KillSwitchEvaluator: shared device+version check for both channels; ingest
wire response and check order unchanged (device first, short-circuits)
- Every served Kill emits ops event KillSignalDelivered (Security/Warning),
throttled 24h per tenant+serial+pattern; Blocks stay event-free
- GET agent/config (incl. bootstrap wrapper) now returns DeviceBlocked/
DeviceKillSignal/UnblockAt alongside the full config; old binaries ignore
the new fields
- Web: KillSignalDelivered dual-registered in OPS_EVENT_TYPES
Agent V2 (effective with next agent release):
- CheckConfigKillSignal guard after the Phase-4 fetch: forced self-destruct
(overrides SelfDestructOnComplete), enrollment-complete marker, session
cleared, exit before mTLS/registration
- Kill honoured ONLY from a live fetch: config disk cache strips the flags on
write and read (UnrestrictedMode pattern), guard re-checks LastFetchOutcome
Tests: 9 KillSwitchEvaluatorTests + 7 ProgramConfigKillSignalTests;
Functions 2557/2557, Agent V2 1578/1578, web tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex Medium on 43a87ff7: the delegated subset was built from
TryGetConfigurationAsync, which maps ANY storage failure to exists=false —
transient table throttling made managed tenants vanish from a 200 response,
whereas the replaced scan path rethrew and returned 500.
- New TenantConfigurationService.GetConfigurationIfExistsAsync: strict
point-read (cached, non-creating, null on 404) that PROPAGATES storage
errors; TryGetConfigurationAsync now delegates to it and keeps its
fail-closed mapping for agent security gates.
- Delegated config/all reads via the strict method → a read failure fails
the request (500) instead of shrinking the subset.
- Adjacent regressions from the same hunk: AllowedTenantIds deduped
case-insensitively (old HashSet-filter semantics), and ids missed by the
exact-case point-read get one scan+filter rescue pass — config PartitionKey
casing is not guaranteed (agent ingest auto-creates rows verbatim; same
caveat the delegated sessions fan-out handles case-insensitively).
Functions suite 2548/2548 (9 new tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unauthenticated /oauth/* routes carried no throttle (accessGuard covers
/mcp only), making /oauth/token an anonymous amplifier: every POST triggers
an outbound Entra call with the app's client_secret attached, so a flood
would get the app registration itself throttled by Entra — an org-wide auth
DoS requiring zero credentials.
- Per-source-IP sliding-window throttles as per-route middleware inside
createOAuthRouter: 20/min on /oauth/token, 60/min on register/authorize/
callback (MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE / MCP_OAUTH_RATE_LIMIT_
PER_MINUTE). Own bucket map keyed `${kind}:${ip}` so OAuth floods never
burn the /mcp pre-auth budget and vice versa; pruned by the shared reaper
and capped by MAX_RATE_BUCKET_ENTRIES. 429s reuse respondRateLimited, so
the existing `[mcp-auth] 429 kind=` log/KQL pattern covers the new kinds.
.well-known discovery docs stay unthrottled (static JSON, no outbound work).
- grant_type allowlist on /oauth/token: missing -> invalid_request, anything
but authorization_code/refresh_token -> unsupported_grant_type, rejected
before the client secret leaves the building. Policy now lives in the
proxy instead of relying on Entra-side accidents (pinned scope, uncopied
fields) to reject client_credentials/ROPC/device-code.
- Tests: oauth-rate-limit.test.ts (tiny pinned budgets, bucket independence
in both directions) + grant_type allowlist block in oauth-register.test.ts.
Env pins in security-guards.test.ts hoisted above the first oauth.js
import — oauth.ts now (transitively) loads access-guard.ts at import time.
MCP server v1.4.5 -> 1.4.6. vitest 345 passed / 41 skipped, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta-review 2026-07-02 findings:
- L2: HelloTracker wait-timer re-arm races Stop()`s lock-free timer dispose —
an ObjectDisposedException on a threadpool timer thread kills the process
on net48. Swallowed with a debug log (shutdown is winning anyway).
- L3: MaybeEmitAgentLateStart gets the same _post==null guard as its siblings.
- L4: agent_late_start / lowObservationCoverage mixed clock epochs (boot =
now-minus-uptime at termination vs wall-clock agentStartUtc). The handler
now captures a (tick, wall) anchor pair at construction and derives boot in
that epoch, so NTP corrections between start and termination no longer skew
bootToStartSeconds.
- L6: terminal starved-app sweep TOCTOU — snapshot-then-emit could race a
concurrent live app_install_starved and double-report. Replaced the
read-model snapshot property with an atomic TryClaimStarvedUserEspAppReport
against the live path`s dedupe set (tracker → host → surfaces → read model).
- L7: a quarantine marker whose delete persistently fails (AV/ACL) re-wiped
the FRESH run`s state on every restart. After a successful quarantine the
stuck marker is neutralized with a processed-sentinel; the next start only
retries the delete.
- L9: DeviceInfoHost is created (and thus subscribes) BEFORE EspAndHelloHost,
whose Start can immediately post EspPhaseChanged(DeviceSetup) — the
enrollment-start re-collect trigger was missed on sessions failing before
the end trigger.
- L16: UserEspKeepAwakeHost no longer holds _gate across the blocking
controller Engage (Idle→Engaging→Engaged claim; a racing Release wins and
the engage path rolls the OS hold back).
- L18: Office preinstalled short-circuit — accepted-risk comment documenting
the silent-miss window (never a false office_install_failed).
- L19: durationSeconds carries a durationBasis discriminator (script_runtime
vs cycle_including_reporting_latency) so platform and remediation durations
are never compared as equals.
- L21: stale ShellCoreTrackerAdapter comment reference removed (ARCH-F7).
(L8 was fixed with M1 in the parked-tripwire commit; L11 is moot since M6
made the prefetch fingerprint static.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta-review 2026-07-02 findings:
- L12: `cmd /k` leaves a fully interactive shell after running its command —
it was classified as a scripted invocation and silently ignored (deliberate
false-negative seam, e.g. a technician-planted `cmd /k whoami`). Now
surfaced as low-confidence `interactive_console_with_command`; only /c
(run-and-exit) is ignored.
- L13: the WMI trace arm + startup probe ran synchronously in the
orchestrator`s sequential host-start loop; a WinMgmt hang (documented OOBE
failure mode) delayed every later host. Both now run on a background
thread, with a dispose-race guard around the watcher arm.
- L14: the desktop-arrival callback was the ONLY stop path — if the DAD never
validated a real-user owner, the watcher flagged ordinary post-desktop user
consoles for the agent`s whole remaining lifetime. Fallback: the host also
stops on a DesktopArrived or HelloResolved signal on the decision rail.
- L15: wtsErrorCount counted the routine "session has no logged-on user yet"
OOBE case as an error; ProcessOwnerLookup.ViaWts now reports real API
failures separately and only those are counted.
- L20: WatchedProcessNames is now actually the single seam — WQL trace
filter, startup probe and the emitted processName are all built from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta-review 2026-07-02 findings:
- M4: StartupEventGate persisted the new fingerprint INSIDE ShouldEmit, before
the caller emitted — a process death between the save and the spool append
suppressed that event for the rest of the enrollment (permanent for static
payloads like tpm_info / console_prefetch_detected). ShouldEmit now claims
in-memory only; the new MarkEmitted(key) commits to disk AFTER the emission,
and SaveSafe writes the committed view only, so other keys` uncommitted
claims never ride along. All five consumers updated.
- M3: disk_space_low hysteresis latch was an in-memory bool → the Warning
(ImmediateUpload) re-fired after every stall restart on a disk-starved
device. Latch is now seeded from + persisted via the StartupEventGate (new
read-only HasFingerprint peek); the re-arm transition persists too.
- M5: ConsolePrefetchScanner compared timestamps from two clock epochs
(.pf last-write vs now-minus-uptime); an OOBE NTP correction could swallow a
real Shift+F10 hit. 10-min skew tolerance on the after-boot test.
- M6: prefetch Warning is once-per-enrollment now — the old fingerprint
contained prefetchLastRunUtc, which every ESP `cmd /c` installer wrapper
refreshes, so multi-reboot sessions re-warned per restart. (Also moots L11.)
- L17: a geo re-lookup that only feeds the pending timezone retry no longer
emits a device_location failure after an earlier success; outbound_ip got
its own latch instead of riding the device_location one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta-review 2026-07-02 findings:
- M1: session_parked_without_deadline evaluated the parked predicate on the
very step that produced the state, so the healthy Classic window
(HelloResolved -> AwaitingDesktop, no deadline, desktop seconds away) fired
a spurious Warning AND consumed the one-shot, masking a real variant-4 park
later in the run. The tripwire now only arms a 10-min dwell timer; it fires
(and consumes the one-shot) only if the session is still parked when the
dwell elapses. Steps that clear the parked condition cancel the pending
arm; parked pass-throughs do not re-base it. Processor is now IDisposable
(timer), disposed by the orchestrator.
- L8 (rides along): the tripwire post moved off the ingress worker onto the
timer thread — a full channel can no longer self-deadlock on it.
- M2: ImeUserSessionCompletedUtc was set-once, so an early defaultuser0
completion permanently failed the AdvisoryCompletion conjunction and
resolved genuinely-completed sessions as Failed. New rule: first
post-AccountSetup observation wins (one upgrade of a pre-anchor stamp,
then frozen). Pure function of (state, signal) — replay-safe.
- L5: HandleAdvisoryCompletionDeadlineFired`s esp-exit anchor is presence-only
now; the old >= AccountSetupEntered comparison rejected fires whose arming
exit carried a backfilled source timestamp, re-opening the dead-end.
deadlineStillArmed remains the stray-fire guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta-review 2026-07-02 findings:
- H1: shutdown force-flush emits an agentexecutor_fallback completion, but the
emitted-marker set and pending buffer were memory-only. After a restart the
tracker parsed IME`s later-written PS-SCRIPT-RESULT line fresh and emitted a
duplicate script_completed. Persist both in ImeTrackerStateData (null-safe
for old state files); pending scripts continue their grace window after a
restart instead of being dropped.
- L1: stale-result guard — a PS-SCRIPT-RESULT older than the pending slot`s
StartedAtUtc belongs to a previous run of the same policy; drop it instead
of merging run 1`s result into run 2`s slot (which also swallowed run 2`s
genuine result via the emitted-marker).
- L10: script_timeout_suspected one-shot dedup moved from the adapter`s
in-memory set to tracker state (TryClaimScriptTimeoutSuspected), so the
Warning stays one-shot across stall restarts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old route /api/diag-files-reports was not covered by any
clientCertExclusionPaths prefix on the Function App, so the platform
cert gate (clientCertMode=Required) prompted portal users for a client
certificate. /api/diagnostics is already excluded, and the endpoint
submits diagnostic files, so it moves under that prefix instead of
growing the portal-only exclusion list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hybrid / Self-Deploying / Blocked pills rendered inline next to the status
badge and widened the status column noticeably. Column layout (flex-col)
stacks them underneath instead, keeping the column narrow regardless of
how many qualifiers a session carries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renders the agent-detected IsSelfDeployingProfile flag: teal pill in the
dashboard session table (next to the Hybrid pill) and "Autopilot
(Self-Deploying)" in the session info card enrollment-type label. The
OobeConfigModal client-side bit decode stays as an independent detail view.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persists the agent-sent self-deploying/kiosk profile marker (template:
IsHybridJoin, write-once): Sessions column + SessionsIndex full-mirror
projection + SessionSummary mapping. Re-registration preserve is a
sticky-true OR - a boot with an unreadable Autopilot policy cache must
not pin the flag to false once it was observed true.
Filterable end-to-end: SessionSearchFilter + continuation-token
fingerprint + search/raw query params + index/basic scan filters, plus
MCP search_sessions / query_raw_sessions inputs and the get_session
overview field.
Display/analytics only - the completion fix itself ships in the agent
(DecisionCore gates on the EnrollmentFactsObserved seed, not this flag).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kiosk devices (self-deploying profile) never completed: the IME logs an
AccountSetup phase line for the kioskUser0 autologon session although user
ESP never runs, which cancelled/blocked the DeviceOnlyEspDetection deadline
- the sole SelfDeploying terminal path - and the session idled to the 5h
backend timeout (reference session 320b3bf7; 436 false Failed platform-wide,
90% of all self-deploying failures).
Platform sweep (8983 sessions / 63 tenants): CloudAssignedOobeConfig bits
0x20|0x40 are exclusive to self-deploying/kiosk/shared profiles (0 of 6785
user-driven sessions carry them, bits always co-occur). The fix seeds
Mode=SelfDeploying@High from that registry marker via EnrollmentFactsObserved
and waives the AccountSetup veto at the four existing decision points
(DSPC short-circuit, deadline race guard B, EspPhaseChanged cancel,
RealmJoin deferred-release re-check) - only when the seeded profile holds
AND user ESP made no real progress. Bits absent = byte-identical behaviour.
Also registers IsSelfDeployingProfile on SessionRegistration (display
metadata; backend persistence follows separately).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M6: DeleteByFilterInBatchesAsync is fail-loud and counts only confirmed
batch submits; the orphan cleanup keeps the EventSessionIndex entry when
the event delete fails, so surviving rows stay discoverable and retry.
M7: GetSessionsOlderThanAsync gains excludeInFlightDeletions - sessions
locked in deletion states no longer occupy the capped read head, so a
stuck head (Poisoned / stranded Queued) cannot starve the retention
fanout tail. Warnings surface a fully-locked backlog and zero-progress
runs.
M8: ingest logs a per-batch warning with per-kind counts and the first
TelemetryItemId when unparseable telemetry items are dropped (agent
clears its spool on success, so drops were silent data loss).
M9: ReconcileSessionRebootCountAsync -> ReconcileSessionCountersAsync;
one partition scan reconciles EventCount and RebootCount at terminal
transitions, self-correcting at-least-once retry double-counts. Script
counters stay increment-only with the drift documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetEntityIfExistsAsync never throws on 404 (handled via HasValue), so the
RequestFailedException catch in GetOrphanedEventSessionsAsync fired only on
transient errors (429/500/503) and marked live sessions as orphans, causing
their events to be hard-deleted. Log a warning and skip the entry instead;
it will be re-checked on the next maintenance run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The V1 agent line is retired: no new V1 sessions since 2026-05-09, only
orphaned straggler traffic remained on /api/agent/ingest (last seen
2026-06-29, events for long-cleaned-up sessions). Bootstrap/Intune/CI
have deployed V2-only since the v2 cutover.
Agent:
- Delete AutopilotMonitor.Agent, .Agent.Core, .Agent.Core.Tests
(projects + sln entries). V2 trio, SummaryDialog, DecisionCore.Tests
and V2Isolation.targets stay.
Backend:
- Relocate the shared statics/DTOs V2 reused from the V1 partials
(StampServerFields, SanitizeEventTimestamps, IsMaxLifetimeAgentShutdown,
RecalculateAppDurations, EventClassification, AppInstallAggregationState)
into EventIngestProcessor; IngestEventsOutput moves next to
IngestTelemetryFunction (same namespace, zero ripple).
- Delete IngestEventsFunction (route agent/ingest), BootstrapIngestEvents
(route bootstrap/ingest), NdjsonParser and their DI registration.
EventDataNormalizer stays (used by TelemetryPayloadParser).
- Drop both routes from EndpointAccessPolicyCatalog and
RequestTelemetryMiddleware.TenantHeaderTrustedPaths.
- Retire the V1 update-hash oracle: LatestAgentV1*/LatestBootstrapV1*
fields + GetAgentLine case 1 (default arm returns empty hashes, so
stragglers gracefully skip the integrity check; ParseAgentMajor keeps
its default of 1 on purpose — defaulting to 2 would feed V2 hashes to
V1 binaries). TableConfigRepository Store+Map updated in lockstep.
- Remove ApiEndpoints.IngestEvents/.BootstrapIngestEvents constants.
Tests: NdjsonParserTests deleted; timestamp/critical-path/max-lifetime
tests repointed to EventIngestProcessor; the two NDJSON pipeline tests
removed (coverage retained by direct-list siblings); catalog/no-store
InlineData rows updated.
Docs: architecture.md (endpoint tables, ingest flow diagram, solution
layout, test matrix), web SectionAgentInternals endpoint tables,
build.ps1 help text.
Azure: no config change needed — /api/agent is not in the client-cert
exclusion paths (agent/ingest was always cert-enforced) and the
/api/bootstrap prefix exclusion remains for the surviving bootstrap
routes (verified via ARM on autopilotmonitor-api).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses four items from the daily code review, plus in-flight Web work and
the .mcp.json endpoint switch to the custom domain.
Review follow-ups:
- MCP access-guard: bound BOTH rate-limiter maps (preAuthBuckets + rateBuckets)
via boundedSet on insert (new MAX_RATE_BUCKET_ENTRIES, default 10_000). They
previously only shrank via the 5-min reaper, so a distinct-key flood could grow
them unbounded between passes. Adds getRateBucketSizes() + a flood-cap test.
- MCP vector-search: assertUnitNorm() in embed()/index() (eps=1e-3, fail-loud).
cosineSimilarity is a bare dot product that assumes L2-unit vectors; a violated
normalize:true contract would otherwise silently corrupt ranking. +unit tests.
- Backend: equivalence tests for the bcd307d SessionsIndex projections, after the
b417963 pattern. Extract MetricsSummaryProjection/QuickSearchProjection named
arrays + testable TallyMetricsSummaryRow/ReadQuickSearchIdentity helpers; make
BuildQuickSearchResult internal. New Metrics/QuickSearch ProjectionEquivalence
tests derive their keep-set from the production array (bite-verified: dropping
Status/DeviceName fails them).
- Agent V2 ModernDeploymentTracker: enforce the Classify() call-once invariant
with a DEBUG-only [ThreadStatic] permit guard. Classify mutates the harmless-
rollup counter, so a double-call double-counts; the invariant was comment-only.
ProcessRecord/ProcessEvent issue one permit before their single call; Classify
asserts + consumes it. Compiled out in RELEASE -> zero production impact.
Also included (per request):
- .mcp.json: point at https://mcp.autopilotmonitor.com/mcp (custom domain).
- Web useAggregatedAdminScope hook + seed + tests (separate in-flight work).
- Agent buildcounter.txt bumps.
Tests: MCP 338, Backend 2551, V2.Core 1538, Web hooks 31 - all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OrphanEventsCleaned ops-event only persisted counts; the per-orphan
detail (which tenant/session lost lingering events) was logged via worker
LogInformation, which does not reach App Insights, so it was effectively
lost. Enrich the event details with a capped (top-50 by EventCount) list of
{tenantId, sessionId, eventCount} plus a detailsTruncated flag, reporting the
actual rows deleted. Keeps the Table row under the 32KB property limit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the sinceUtc bounding (commit "project index scans + bound
AppInstall reads to window"): the AppInstall cutoff was computed with a fresh
DateTime.UtcNow.AddDays(-days) AFTER the session window had already been
queried and several aggregates had run. That second snapshot sits a few ms
later than the session query's, so the AppInstall lower bound was strictly
later than the session lower bound.
A session included right at the window's lower boundary could then keep its
row while app summaries whose StartedAt fell into the gap between the two
cutoffs were dropped — undercounting AppScripts.AvgAppsPerSession and
TotalUniqueApps for boundary sessions.
Capture a single `now`/`windowStart` snapshot at the top of both
ComputeUsageMetricsInternalAsync and ComputeTenantUsageMetricsInternalAsync
and reuse it for GetSessionsByDateRangeAsync(windowStart, …) and
Get(All)AppInstallSummariesAsync(windowStart). Now both bounds are identical.
UsageMetricsWindowConsistencyTests pins it: it captures the start arg passed
to the session repo and the cutoff passed to the app repo and asserts they
are equal (and thus <=), on both the platform and tenant paths. Verified the
test bites — reverting either call to a fresh UtcNow fails it. All 2546
backend tests pass.
Reported-by: Codex review
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>