The 2026-07-08 timeout reclassification (Incomplete = terminal, non-failure)
migrated SessionStats, FleetHealthStats and SessionStatusBuckets, but two
reporting surfaces were overlooked: the live UsageMetricsService.SessionMetrics
(both platform + tenant paths, feeding get_usage_metrics and the usage
dashboards) and the persisted daily UsageMetricsSnapshot. Terminal Incomplete
sessions were counted in Total but in no breakdown bucket, so
Succeeded+Failed+InProgress no longer reconciled to Total and operators lost
sight of those devices.
- SessionMetrics + UsageMetricsSnapshot gain an Incomplete / SessionsIncomplete
count, mirroring the migrated peers (Incomplete only, not AwaitingUser).
- Snapshot column stored + mapped in TableStorageService.Metrics; legacy rows
deserialize to 0 (no seed bump needed).
- Success rate unchanged everywhere: denominator was already Succeeded+Failed,
correctly excluding Incomplete/AwaitingUser. SLA paths verified already-correct
(BuildSnapshot + breach evaluator receive terminal-filtered sessions).
- Web: Incomplete card added to both usage views (slate, matches the badge).
- MCP: query_raw_sessions status enum learns AwaitingUser/Incomplete so callers
can filter by the new states.
- Test: UsageMetricsServiceWindowTests pins the Incomplete bucket + 50% rate on
the crcins 1/1/8 shape.
Backend build clean, full Functions suite 2825 pass, web + MCP tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses four review rounds on the enrollment-status reclassification
(timeout != failure; new AwaitingUser/Incomplete states).
Backend correctness:
- UpdateSessionStatusAsync ETag force-fallback now mirrors the normal path:
uses IsTerminalTransitionAllowed + admin-marked guard, stamps CompletedAt
for Incomplete, and persists FailureReason/FailureSnapshotJson for
Incomplete/AwaitingUser (not just Failed) so a contended write cannot
regress a terminal or drop the classification reason/snapshot.
- StoreSessionAsync CAS loop re-reads the terminal tuple in the fresh-read
window and preserves a just-landed terminal (Succeeded/Failed/Incomplete)
verbatim, so re-registration can no longer revert it via the Replace.
- SessionGraceHours + AbsoluteMaxSessionHours now roundtrip through the
tenant-config Store/Map pair (overrides were silently lost).
- ResolveGraceHours clamps the assumed agent cap to >= the real runtime
default (48h) until the agent honors the override, so a low override can
never terminalize a still-running session early.
- Incomplete now funnels through the terminal counter-reconcile seam, and
the maintenance sweep re-reconciles after the synthetic session_timeout
event (also fixes the pre-existing off-by-one on the Failed path).
Web:
- SessionInfoCard delegates its status pill to the shared SessionStatusBadge
(removes a divergent local map; picks up AwaitingUser/Incomplete).
- SessionTable search color map + status filter chips learn the new states.
- useSessionEvents uses isTerminalStatus() instead of a hard-coded
Succeeded/Failed check (no needless refetch on Incomplete).
- DataManagementSection copy no longer says "Failed - Timed Out".
Docs/comments:
- Plan doc + TenantConfiguration comments corrected (+3h/51h buffer, and the
timeout now reclassifies to AwaitingUser/Incomplete, not "Failed").
Tests: +ResolveGraceHours clamp case, +grace/abs-cap config roundtrip +
legacy-row defaults. Full backend suite green (2712), web tsc green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows Update activity during Autopilot OOBE/ESP is a blind spot no other tool
(Intune console included) surfaces well — a cumulative update installing mid-OOBE
can break enrollment (r/Intune KB5095189). This makes it visible with secured
evidence.
Agent (net48):
- WindowsUpdateTracker + WindowsUpdateWatcherHost: watch
Microsoft-Windows-WindowsUpdateClient/Operational (EventIDs 19/20/43/44 =
success/failure/install-started/download-started), startup backfill (OOBE updates
run before the agent starts), RecordId watermark dedup (restart boundary vs
intra-run seen-set so a high-id live event can't hide older backfill events),
HRESULT->symbol decode. Emits windows_update_succeeded/_failed/_started with the
real event Timestamp. Default-on config knobs; uncertain EventIDs configurable
without redeploy.
- RegistryCollector: emitOnlyIfExists (suppress positive-assertion events when the
key is absent) + Registry64 base view by default (bitness-independent, matches
TenantIdResolver) with optional registryView override.
Rules:
- ANALYZE-DEV-004 (high: WU failed during enrollment, high-insight not KO) +
ANALYZE-DEV-005 (info: WU installed). reboot_pending corroboration matches
event_data exists==true.
- GATHER-DEVICE-005 -> windows_update_reboot_pending (emitOnlyIfExists),
GATHER-DEVICE-004 -> windows_update_history (Get-HotFix, opt-in).
- 5 EventTypes synced across Constants + MCP resource-catalog + web eventTypes.
Self-maintaining rule lifecycle (analyze + gather parity):
- Gather rules gain the sunset/orphan-GC that only analyze had
(ProcessSunsetGatherRuleAsync + runtime filter + seed/reseed/github paths).
- RuleProvenance field (embedded/github, null=embedded back-compat): the embedded
catalog only sunsets its own rules; GitHub-reseeded rules ahead of the deployed
binary (new ids AND content updates to existing ids) are exempt and reclaimed to
embedded once the binary catches up. No orphans, no operator knowledge required.
Verified: full solution builds; backend suite 2770 pass, agent suite 1638 pass;
rules AJV-valid + combined.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the agent's silent 48h absolute session-age break visible: on break, before
cleanup/exit, fire a SessionAgeEmergencyBreak AgentErrorReport over the resilient
emergency channel; the backend (Part B) materializes it as an agent_emergency_break
timeline event and the timeout classifier terminalizes the session instead of
waiting out grace.
Decoupled so the guard stays pure and its unit tests are untouched:
- CheckSessionAgeEmergencyBreak gains optional Action onBreakFired = null, invoked
once when the break is confirmed, before marker/cleanup, fully swallowed.
- New EmergencyBreakReporter.TrySend builds a throwaway auth bundle
(BackendClientFactory) and sends, bounded to a 5s wait. AgentBootstrap wires it;
GetAgentVersion made internal.
NOTE: agent targets net48 and was NOT compiled in this environment (only the .NET 8
SDK is available) — to be built/verified locally. The Shared AgentErrorType value is
verified (compiled in the backend build).
- SessionStatusBuckets: explicit AwaitingUser + Incomplete buckets (out of Other),
still reconciling to Total.
- Metrics summary: expose awaitingUser + incomplete; failure rate is now
Failed/(Succeeded+Failed) — Incomplete and non-terminal states are out of the
denominator (crcins drops from failed/total to the real terminal rate).
- SessionStats.IncompleteLastNDays; dashboard SuccessRatePct was already terminal-only.
- Daily aggregation already used Succeeded+Failed denominator — unchanged.
- Tests: SessionStatusBucketsTests. Suite: 2705 pass. Fleet Health Incomplete surfacing
deferred to PR5 (web) with its documented over-all-sessions semantics.
A device that reaches the desktop is enrolled regardless of an earlier sweep
timeout guess, so a genuine completion must be able to heal a prior verdict.
- Replace the terminal idempotency guard with pure IsTerminalTransitionAllowed:
Succeeded UPGRADES any non-Succeeded state (Failed/Incomplete/AwaitingUser →
Succeeded); Failed/Incomplete and AwaitingUser never overwrite an existing
terminal. Ingest already calls UpdateSessionStatusAsync(Succeeded) on a late
enrollment_complete without a pre-terminal skip, so the relaxed guard wires the
reconcile end-to-end.
- Clear stale FailureReason + FailureSnapshotJson on Succeeded (reconcile hygiene).
- Preserve admin-marked terminals: a late completion never auto-overrides a
hand-marked Failed/Incomplete.
- Tests: SessionStatusTransitionTests (full matrix, 20). Suite: 2705 pass.
ReportAgentErrorFunction now injects ISessionRepository and, on a
SessionAgeEmergencyBreak report, writes an agent_emergency_break EnrollmentEvent
into the session stream (Sequence one past the last event, idempotent, best-effort
— never turns the always-200 emergency channel into a retry loop). This is what
makes the agent's otherwise-silent 48h break visible in the timeline and lets the
timeout classifier see it. Pure BuildAgentEmergencyBreakEvent helper (Warning,
non-terminal) + tests. Functions.Tests: 2685 pass.
Groundwork for making the agent's silent 48h emergency break transparent
(docs/design/enrollment-status-reclassification.md):
- Constants.EventTypes.AgentEmergencyBreak + AgentErrorType.SessionAgeEmergencyBreak.
- EnrollmentTimeoutClassifier: ExtractRollup.HasAgentEmergencyBreak; classify a
session carrying the break as Incomplete NOW (agent is gone) instead of holding it
as AwaitingUser through the grace — a real completion still wins. Tests added (22 total).
Next: agent best-effort emit at the break + backend materializes the timeline event.
A completion can only arrive <= the agent's absolute cap (it self-destructs and
sends nothing after), so the grace buffer needn't wait hours for stragglers — it
only has to span one 2h maintenance-sweep cycle + minor clock skew, and reconcile
heals any premature Incomplete. Buffer 12→3h, default grace 60→51h.
The 5h→terminal grace was a magic 72h. Anchor it to the agent instead: the 6h
per-run brake emits enrollment_failed (→ Failed at ~6h), but the 48h absolute
emergency break (AbsoluteMaxSessionHours) is SILENT to the backend — it writes a
local marker, cleans up and exits with no terminal event. So a session only stays
non-terminal past 48h when the agent hit that silent cap and is provably gone.
Grace must therefore be >= the agent absolute cap (never mark Incomplete while the
agent could still legitimately enroll) and only slightly beyond it:
effectiveGrace = max(AbsoluteMaxSessionHours + 12h buffer, SessionGraceHours) = 60h default.
- EnrollmentTimeoutClassifier.ResolveGraceHours (pure, tested): floors grace at
agentCap + buffer; a SessionGraceHours override can only raise it.
- TenantConfiguration.SessionGraceHours default 0 (= auto-derive); mirror
AbsoluteMaxSessionHours so the floor follows a tenant override of the agent cap.
- Sweep uses ResolveGraceHours instead of the hard-coded 72h.
Follow-up noted: wire AbsoluteMaxSessionHours down into AgentConfigResponse so the
agent and backend share one value. Functions.Tests: 2678 pass.
Wire EnrollmentTimeoutClassifier into MarkStalledSessionsAsTimedOutAsync so the
5h inactivity sweep stops blindly failing silent-but-provisioned sessions
(docs/design/enrollment-status-reclassification.md). Forward-only.
- TenantConfiguration.SessionGraceHours (default 72h) + default config.
- Sweep Stage 2: classify from the ESP subcategory rollup instead of hard Failed.
Fast-path skips within-grace AwaitingUser sessions with NO event read (grace is
time-based; late completions are caught by ingest). Per-outcome counters; audit
+ ops event only when the pass actually terminalized/transitioned something.
- session_timeout event + auto-analyze kept ONLY for Failed/Incomplete (parity with
the agent max-lifetime path); AwaitingUser is non-terminal, Succeeded is a reconcile.
- GetStalledSessionsAsync includes AwaitingUser so it graduates to Incomplete once
grace elapses; agent-silent (Stage 1) stays InProgress-only.
- UpdateSessionStatusAsync: Incomplete is terminal (idempotency); AwaitingUser can't
regress a terminal; CompletedAt stamped for Incomplete (no skewing duration); reason
+ snapshot persisted for Incomplete/AwaitingUser.
Full Functions.Tests suite: 2670 pass.
Foundation for reclassifying 5h-timeout sessions (docs/design/
enrollment-status-reclassification.md). No behaviour change to the sweep yet —
this only adds the pieces PR2 wires in.
- SessionStatus: append AwaitingUser (non-terminal) + Incomplete (terminal,
non-failure). Members appended so persisted ordinals never shift.
- EnrollmentTimeoutClassifier: pure ExtractRollup (parses the esp_provisioning_status
"N of M subcategories completed" message + the 30s all-succeeded fallback line —
categorySucceeded is ignored, Windows never sets it) and ClassifyTimedOutSession
implementing the decision table (explicit failure→Failed; AccountSetup all-succeeded
/ enrollment_complete→Succeeded; DeviceSetup done + within grace→AwaitingUser, else
Incomplete; silent pre-DeviceSetup→Incomplete). Never returns Failed without an
explicit failure signal.
- FailureSnapshotBuilder: embed the same rollup (schema v2) so the snapshot records the
evidence behind the verdict.
- Tests: EnrollmentTimeoutClassifierTests (12). Full Functions.Tests suite 2670 pass.
Design note + implementation plan for splitting the 5h maintenance-sweep
"Failed" bucket into three states. Evidence (crcins.com, 90d): 90.7% of
"failures" are 5h inactivity timeouts with no explicit failure event; 99.1%
of those had DeviceSetup fully provisioned (device enrolled) and simply had
the agent go silent during the user/Account-Setup phase. True failure rate
~4-5%, not 57.2%.
Proposal (backend + stats only, no agent change / no heartbeat):
- add AwaitingUser (non-terminal) + Incomplete (terminal, non-failure) states
- classify timed-out sessions from the ESP subcategory rollup the agent
already emits (gate on subcategories, not the unreliable categorySucceeded)
- passive grace window owned by the backend; reconcile late completions to
Succeeded; exclude Incomplete from the failure-rate denominator
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>