App install rows now carry their install channel. The ingest folds the RealmJoin
watcher's package started/completed events into AppInstallSummaries rows (channel
"realmjoin", keyed by scope + package id; duration = completion minus the observed
start). IME rows keep their key and have no channel column, so they read as before.
Every per-app aggregate groups on (channel, name): apps list, app analytics and
sessions (new ?source=, default ime), metrics/app, SLA top failing apps and the
version-regression radar (tracker key, episode key, bell link, ops event). The app
install SLA rate includes RealmJoin packages. The ESP-blocking stamp stays IME-only.
RealmJoin packages do not feed the software inventory or CVE correlation.
Web: a RealmJoin pill and an All / Intune / RealmJoin filter on Software > Installs,
the pill on app detail, Fleet Health and SLA, source-aware app links and PDF meta
line, and a hint in the empty Inventory tab. The pill moves out of InstallProgress
into a shared component. MCP: get_app_install_metrics names the source field.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The platform forwards whatever certificate the TLS client presented, so an
unauthenticated caller can hand in a leaf signed by a CA it made up. The
chain engine then fetched the leaf's AIA caIssuers URL before rejecting it:
an attacker-directed GET from the backend, and a worker blocked for the
10 s retrieval timeout. Every legitimate issuer is embedded and Intune
certificates carry no AIA, so downloads are now disabled and the unused
retrieval timeout is gone.
The regression test signs a leaf with a CA that is neither embedded nor
sent and asserts that the listener behind its AIA URL sees no connection;
a self-signed leaf never reaches that path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.
- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
read with ETag, tombstone refused, mutation decides, conditional replace, conditional
insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
Regenerated shared manifest and wire types (web, MCP).
Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).
Bumps internal (D-290, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Offboarding deletes the tenant configuration, and the returning tenant's first login recreated
it with TrialConsumed=false, so the one-time 30-day Pro trial could be chained by offboarding
and re-onboarding.
- The offboard click reads the configuration fail-loud and ORs TrialConsumed into the
OffboardingByTenantPointer (sticky across rounds, same ETag CAS as OffboardCount).
- The first-login write (HandleNewTenantDomainAsync, the one write that sets OnboardedBy)
carries it onto the new row; an unreadable pointer means no write, retried next login.
Re-onboarding itself is unchanged, auto-approve included.
- TenantConfigurationService.UpdateAsync: fresh projected row with ETag, offboarding
tombstone refused (403 TenantSuspended), mutation may decline, conditional replace with
the ordinary pre-write backup, re-read and re-decide on a lost race (409 after 3).
SetTenantPlanTier and StartTrial use it, so a warm instance's cached row is never saved
(it could lift the tombstone during the drain barrier) and two concurrent trial starts
cannot both pass.
- Tests: TenantConfigUpdateTests (service + both endpoints), pointer stickiness and the
offboard endpoint in TenantOffboardFunctionTests, first-login carry-over in
AuthFunctionSideEffectTests; shared EndpointHarness.
Bumps internal (D-288, D-289, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SsrfGuard.ValidateDestinationAsync resolved the webhook host and checked
it, then HttpClient resolved it again for the POST, so a DNS-rebinding
host could pass the check and be connected to an internal address.
The webhook client's SocketsHttpHandler now connects through
SsrfGuard.ConnectToPublicAddress: it resolves once per connection,
refuses the connection if any address is blocked, enforces HTTPS and
connects the socket only to the checked addresses; TLS still runs against
the request host. The pre-flight check is removed, the registration moves
to AddWebhookNotificationHttpClient so a test can prove the wiring, the
notification retry policy no longer retries a refused destination, and
the test endpoint reports the refusal with the same message as before.
IsBlockedAddress follows the same model as the MCP server: all IANA
special-purpose space, IPv6 only 2000::/3, zone ids and IPv4-mapped
addresses refused (IPNetwork.Contains judges a mapped address by its
IPv4 form, so those are refused first).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The SSRF gate resolved the client_id host, checked the answer and then
handed the hostname to fetch(), which resolved it again at connect time.
A DNS-rebinding host could pass the check with a public answer and be
connected to an internal one.
The metadata fetch now runs through a dedicated undici dispatcher whose
connect.lookup is the gate: one resolution per connection, every address
must be public, the socket connects only to those addresses. The global
fetch is untouched.
The address classifier now refuses every IANA special-purpose block
(IPv4), allows only global unicast 2000::/3 minus its special-purpose
blocks (IPv6), and refuses zone ids and IPv4-mapped addresses. The tests
enumerate the unedited IANA registries (tests/fixtures/
iana-special-purpose-addresses) and prove on loopback sockets that a
refused host opens no socket and that an allowed one is connected to
exactly the address the gate returned.
Adds undici ^8.11.2 (engines >=22.19.0).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When ipinfo.io (plus retry) and ifconfig.co both fail, the agent now asks
geo.prod.do.dsp.mp.microsoft.com/geo. It returns only CountryCode and
ExternalIpAddress, but it belongs to the DO/Windows Update endpoint set Autopilot
needs anyway, so it stays reachable where public geo services are blocked.
The session then gets a country-only device_location (source "do-geo", no
timezone, so timezone auto-set stays pending) instead of none.
- GeoLocationService: the three providers share one TryProvider skeleton;
response parsers are internal and unit-tested; GeoLocationResult.Describe()
skips missing parts ("DE" instead of ", , DE") in the event message and log.
- Failure trace carries doGeoError + lastResortProvider.
- Backend: geographic location keys skip empty region/city, so country-only
sessions group as "DE" rather than ", DE"; drilldown still round-trips.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ClientAppNotRegistered refusal (D-279, now enforced) sends Tenant Admins
to this form; its hint required access_as_application, which an app acting
on behalf of users does not need.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each save handler still builds the edited configuration but now sends changedAdminConfigFields(loaded, edited) through lib/adminConfigSave.ts, and nothing at all when nothing changed. This covers the Global Settings, the diagnostics paths, the ops alerts and the vulnerability section. The MCP access policy patches its single field without reading first. A page loaded before another writer (the agent release pipeline, a second operator, a sync job) can no longer revert that writer's fields. Deployed after the backend that serves PATCH global/config.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On 2026-09-25 a Global Settings save from a page loaded before an agent release wrote the whole configuration back and reverted the release's LatestAgentV2* hashes. After that, every 2.0.1465 agent reported IntegrityCheckFailed. The flaw was structural: every writer (portal PUT, plan tiers, three vulnerability-sync stamp paths) replaced the whole row from the copy it held, usually the 5-minute cache.
- IConfigRepository.UpdateAdminConfigurationAsync(mutate, updatedBy) is the one write path. It reads the row fresh with its ETag and applies the mutation to a copy materialized from that read. It computes the changed columns from the converter projections and writes only those onto the freshly read raw entity; a null value drops the column. The replace is conditional, and a conflict triggers a re-read and retry. A no-op writes nothing. Pipeline-owned and unknown columns survive. The full-model SaveAdminConfigurationAsync / SaveConfigurationAsync are removed, and the first row is created with AddEntity.
- PATCH global/config with { fields } (PatchAdminConfigurationRequest) replaces PUT/POST. Its field gate is derived from the persisted columns and rejects server stamps, LatestAgentV2* / LatestBootstrapV2ScriptVersion, PlanTierDefinitionsJson, the sync stamps and redacted placeholders. The route catalog lists PATCH.
- Plan tiers, TriggerMsrcSync, TriggerVulnerabilityDataSync, VulnerabilityDataSyncFunction and VulnerabilityCacheRefreshService.StampAsync each hand in a mutation.
- Tests: AdminConfigurationChangedFieldsTests (stale-page toggle keeps the release hashes, concurrent pipeline write re-read and kept, no-op, rejection, null drop, first row, gate); existing tests adapted; the request guard allows the admin field map.
- Regenerated shared manifest and wire types (web utils + MCP). The web still sends PUT until the web commit that follows the backend deploy.
- The internal pointer is bumped to D-285.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A portal-registered client (amc_) now receives its Entra refresh token only as a compact JWE (RFC 7516, dir + A256GCM, kid amc-rt-v1). The key is derived from OAuthStateSigningKey. At refresh, the registration comes from the envelope, never from the client_id parameter. Before, a copy of the token redeemed without client_id went to the organizations authority and skipped the registration check.
A raw refresh token presented with an amc_ client id is accepted and returned sealed, so these clients migrate on their next refresh. All other clients are unchanged. jose becomes a production dependency; it was a dev-only transitive dependency before. The internal pointer is bumped to D-283/D-284.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The option is a regular tenant-admin toggle in its own "Without pre-registration" block, with the
add-on permission state read from the existing graph-permissions status endpoint. The dashboard
banner and the admin Ready badge key on "any validation method enabled" (feature flag
deviceValidationEnabled, manifest-driven validate* fields) instead of Autopilot alone, so a
device-preparation tenant that validates by device association is no longer told that agent
ingestion is blocked. Stale "shadow mode" wording for Device Association is gone, the
Not-registered report names every method, sessions show "Intune Enrollment", and the privacy
page and FAQ list the new method. Grant script help describes the IntuneDeviceBinding feature
as Intune Enrollment Validation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The certificate's Intune managedDevice id is looked up in the tenant's Intune. With the tenant
switch ValidateIntuneDeviceBinding on, that lookup is the last accepting link of the validator
chain: an enrolled device that is not being retired, wiped or deleted is admitted as
ValidatorType.IntuneEnrollment. A 404 for a certificate younger than 60 minutes is
NotFoundRecentEnrollment (503, never cached); a missing grant is 503 with 120 s.
For devices another validator admitted, the lookup only observes: in the background, never
admitting or rejecting, and only when the app token carries
DeviceManagementManagedDevices.Read.All, so a tenant without the add-on costs no Graph call.
Outcomes ride on the request row (CertDeviceBinding, role, serial match, owner and enrollment
type).
TenantConfiguration.HasAnyDeviceValidation() is the single gate precondition; the feature flags
expose it as deviceValidationEnabled (replaces validateAutopilotDevice).
The agent parses ValidatorType strictly: release the agent before this backend.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The registrations (name, callback URL, client id) live in their own table,
not in TenantConfiguration, so the report only showed the limit. Fetch
GET tenants/{tenantId}/mcp-client-registrations fail-soft alongside the
other side views and render them in the Tenant Status section, plus the
platform switch. The limit row is relabelled to say it is a limit.
Claude-Session: https://claude.ai/code/session_01DbRs7NCppizQ7wWFjN7n3M
Co-authored-by: Claude <noreply@anthropic.com>
The query recognizes only npm limiter packages, never oauthRateLimit /
oauthTokenRateLimit, and flags a handler as soon as it calls a function
named like *auth*, *login* or *verify*. All six alerts it ever raised were
on throttled OAuth routes (the last two came from tenantAuthority()).
What it stood in for is now a test: every route of the OAuth router must
run a per-IP limiter first unless it is one of the three named discovery
documents, /oauth/token must carry the strict budget, and a router.use()
middleware fails the check. Verified by mutation: removing the limiter
from /oauth/authorize fails the test with the route name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The registration lookup put a client_id-derived value into the backend URL
path unencoded. The amc_ regex already limits it to 32 hex characters, so
nothing changes at runtime, but every other request-derived path segment in
the MCP server goes through encodeURIComponent, and CodeQL counts only that
(or a ?/# prefix) as a request-forgery barrier - a regex capture group is
not one. Closes code-scanning alert js/request-forgery on oauth.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A ?serial= parameter (serial number or exact device name) runs one lookup
as soon as sign-in has resolved the tenant, so organisations can send users
a link to follow their own device. Every hit writes the found session's
serial back into the URL for reload, bookmark and copying. Authorization is
unchanged: sign-in stays mandatory and roleless callers still need the exact
serial server-side. The query survives the login redirect because
ProtectedRoute keeps pathname + search. progress_serial_submitted carries
source form | link.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Settings > Tenant > AI Integration (between SLA Targets and Bootstrap
Sessions) explains the normal connection first - server URL for Claude,
ChatGPT, VS Code and other hosted assistants, nothing to register - and
shows the self-hosted client registration below it (Tenant Admin, platform
switch on). The page is visible to admins, platform scopes and anyone with
MCP access. The self-hosted example text is neutral.
- A tenant may hold one self-hosted client registration. The GA-only
TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request:
reverted for non-GA callers on PUT, refused on PATCH, edited in the Global
Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A self-hosted AI client redirects to its own domain, which the vendor
redirect allowlist never admits, so it could not use the browser sign-in.
Tenant Admins can now register the client's exact OAuth callback in the
portal and configure the client with client id amc_<registrationId>.
- MCP OAuth proxy: an amc_ id is resolved through the backend; its
registration is the redirect allowlist entry for that exact callback.
Authorize and token run at the registering tenant's Entra authority, and
the token endpoint discards a token of any other tenant (code and
refresh). Lookups are cached for 60 s, a failed lookup fails closed. Every
other client keeps the allowlist check before its resolution.
- Backend: table McpClientRegistrations, TenantAdminOrGA routes
tenants/{tenantId}/mcp-client-registrations (list, create, delete; at
most three, https or loopback http, exact URI, audited) and the anonymous,
per-IP rate-limited lookup auth/mcp/client-registrations/{id} (under
/api/auth for the client-certificate exclusion list).
- AdminConfiguration.McpClientRegistrationEnabled (default off) is the
platform switch: off hides the portal section and makes every lookup 404.
auth/me carries it for the sidebar.
- Portal: Settings > Tenant > Self-hosted AI clients (Tenant Admin / Global
Admin), and the switch in Admin > Settings > Global.
- Tests: MCP tenant-client flow (authority, tid check, revocation, cache,
allowlist), backend round trip, wire parity, validation, cap, switch,
audit; nav visibility. Manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy issues no client secrets, but its authorization server metadata
named no token_endpoint_auth_methods_supported (RFC 8414 then implies
client_secret_basic) and /oauth/register echoed a requested secret-based
method. A client following that default registered with client_secret_basic
and then failed the token exchange ("client_secret_basic authentication
requires a client_secret"), reproduced with the SDK calls a self-hosted chat
client makes. The metadata now lists ["none"] and registration always answers
none (RFC 7591 lets the server replace a requested value).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A user token obtained by an application other than the platform's own
registrations (for example a self-hosted AI client using on-behalf-of) was
accepted with the user's full role; only audience, issuer, signature and
lifetime were checked. Such tokens are now classified on every request.
- ClientAppBindingMiddleware (after authentication, before policy enforcement)
records the request dimensions ClientAppBinding (registered | unregistered |
unidentified) and ClientAppId, plus a throttled Warning.
- AdminConfiguration.EnforceClientAppBinding (default off; Admin > Settings >
Global): when on, a foreign client is refused with 403 ClientAppNotRegistered
unless the application is an enabled member of the caller's tenant (the
app:<client-id> row a service principal uses). An admitted caller keeps their
identity but gets the application caps: at most Viewer, no platform role,
DelegatedReader on managed tenants, roleless tiers closed except auth/mcp.
- AdminIdentity.IsCapped (application or admitted foreign client) is what the
global, delegated and member-role resolvers switch on; RequestContext carries it.
- Own clients = the audience trust set plus EntraId:TrustedClientAppIds, a
client-only trust for the local development registration that never widens
the accepted audiences.
- Tests: classification, unforgeable cap marker, middleware in both modes, caps
through the real policy middleware, pipeline order; manifests and wire types
regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A backend 401 on `initialize` logged only the UPN, and the backend's own trace
("IDX10214 audience validation failed") never names the token; a registration
rejection logged only a count of redirect_uris. Diagnosing a self-hosted client
(LibreChat) that first failed DCR on its own callback host and then presented
tokens minted for another resource took a customer round trip.
- access-guard: `invalid-token-claims` and `backend-rejected-token` carry
`aud=... azp=... tid=...` (unverified claims; control characters stripped, capped)
- oauth/register: the rejection names origin + path of up to three rejected
redirect_uris (never the query); the error_description names the actual
defaults (VS Code, not Google)
- tests for both lines; skill catalog in .claude/commands (mcp-telemetry) updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Route payloads (__next.*.txt, index.txt), static JSON and the router's HEAD
self-requests were 20k of 62k dependency rows per week and nobody reads them.
lib/webTelemetryExclusions.ts builds the patterns (own origins minus /api/,
plus relative non-API URLs) for two layers: excludeRequestFromAutoTracking-
Patterns for string fetches, and a dependency initializer that drops the
finished item by its absolute target - the SDK's own check sees an empty URL
for the URL objects Next's router passes to fetch (verified against the ingest
batch: 11 such fetches, 0 shipped; API, SignalR and token rows still shipped).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P1 - full loads
- staticwebapp.config.json: /_next/static/* immutable, /landing/* max-age=86400
(SWA default was max-age=30 for hashed assets); guard test pins it
- components/NavLink.tsx: prefetch on hover/focus/touch for the portal chrome
(prefetch={false} also disabled the hover prefetch); landing links stay off
- app/layout.tsx: preconnect to the API origin
- lib/dashboardSeed.ts + lib/prefetchSeeds.ts: the dashboard's first list and
stats requests start with auth/me and are handed to the hooks as one-shot
seeds (the own-tenant list URL carries no tenantId; the backend reads the JWT)
- lib/msalConfig.ts: the login request carries the API scope, so one token
redemption serves the first API call; Graph consent via extraScopesToConsent
P2 - chatter per navigation
- lib/signalrGroupRegistry.ts + contexts/SignalRContext.tsx: group membership
is reference-counted with a 2 s grace leave; reconnect rejoins run in
parallel; on/off/invoke and the context value are memoised; the dashboard's
unconditional leave no longer drops the notification bell's global-admins
membership; consumers pair every join with a cleanup leave
- app/sessions/hooks: both group joins in parallel, the join catch-up is
dropped while the initial walk is in flight, identical session deltas skip
the state update
- lib/cachedAuthFetch.ts: TTL cache for feature flags, tenant list and latest
versions with invalidation on every write path; FeedbackBubble defers its
status call
P3 - pure derivations only (D-274): memoised filter arrays, one-pass phase
summary, status counts, useDeferredValue on both search inputs
P4 - landing
- lib/authHint.ts + AuthGate + globals.css: the loading overlay is applied by
an inline head script only when the site root is opened by a browser with an
auth hint; the prerendered HTML carries no overlay; CSS lifts it after 8 s
- hero as WebP srcset (81 KB instead of a 180 KB PNG), dead landing-v1 CSS
removed, terminal demo pauses off-screen, duplicate twitter image dropped
Telemetry (D-276)
- lib/appInsightsRuntime.ts: the SDK is its own chunk, deferred until idle on
the public host, remote config fetch off (blkCdnCfg), initial trackPageView
on both hosts, Web Vitals as web_vital_* custom metrics
Verified: tsc, eslint --max-warnings 0, vitest 127 files / 1442 tests, next
build, Playwright click-through of every portal route, settings section,
session views and public pages on the production bundle against the live API.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A platform script that ran 10 s was shown as "1h 00m" (session 4377911b): its
start line was self-anchored (+120 min) while its result line, re-read after an
overwrite rewind and therefore never fresh, fell back to the agent's cached zone
(+60 min), was rejected as ahead of the clock, and the duration was taken from
the raw pair. The same shape fired script_timeout_suspected daily in one tenant
on 2-9 s scripts read as 17 h. Fleet 2026-09-20..23: about 1 % of script
durations, 28 of 28 inflated ones carrying derivedTimestamp.
Agent: ScriptExecutionState binds the provenance of its start, result and exit
lines (CmTraceLineProvenance, persisted); EmitScriptCompleted judges the pair -
verified (both measured, or both assumed with the same offset from the same
file), unverified, or suppressed with durationSuppressedReason
(rejected-endpoint | offset-grid, CmTraceOffsetCalibrator.IsOnOffsetGrid) - and
always emits durationProvenance plus startSourceOffset*. A held completion's
sourceOffset* tags now describe its own result line; script_timeout_suspected
only judges a measurable duration; the emitted-run marker is capped at the
clock so a future-shifted result no longer swallows the next run's start.
Web: the reducer applies the same rule to stored events from older agents,
pairing a final with the latest script_started of its policy, and the panel
shows "n/a" with the explanation where the run time would be.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ImeRegistryAppStateObserver reads a fourth IME registry surface,
Win32Apps\OperationalState\<userGuid>\<appGuid>: the install deadline a
downloaded app waits for (ExecutionDeadlineTime, IME 1.106) and the
app-in-use deferral counters (DeferralsUsed, DeferUntilTime,
DeferralMaxDeferrals, DeferralAutoDeferred, IME 1.105). Both travel on the
existing registry_app_state event as data (installDeadlineUtc, deferralsUsed,
deferralMaxDeferrals, deferUntilUtc, deferralAutoDeferred) with the change
triggers installDeadline / deferralsUsed / deferUntil, and the message says
the state in words ("downloaded, install deadline ... UTC", "deferred by user
1/3 until ... UTC", "auto-deferred ..."). No new event type.
ClassifyStatusServiceStatus maps IME's legacy AppInstallStatus explicitly:
1000/1002/1004 installed, 1001/1003 installing, 2000 band notApplicable,
3000 failed, anything else unjudged. The 1000 band was treated as installed,
but IME maps every InProgress enforcement state to 1001, so a running app
counted as terminal success on the registry rail (observability only).
Tests: explicit status mapping, both registry timestamp shapes, the new
change triggers, the wait-state text, and the tick flow for a deadline and a
deferral event. Mutation proof: classifier and trigger broken on purpose
turn exactly the five expected tests red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The offboard endpoint tombstones the tenant (Disabled=true, "Offboarding in
progress") before it answers 202, so the departing admin submits the farewell
feedback from an already-suspended tenant. Since the suspension gate moved onto
the shared authorization path (43ddc601, 2026-08-29) every feedback POST was
403 TenantSuspended — invisible, because nobody clicked and the two stored
feedbacks predate the gate (D-270).
- Endpoint catalog: `AllowedDuringOffboarding` on exactly
POST tenants/{tenantId}/offboard/feedback; the middleware ignores the
offboarding tombstone for that route only. An operator suspension still
denies; policy tier, cross-tenant check and the function's active-marker
check are untouched. Middleware tests for all four cases plus a catalog
ratchet pinning the flag to the one route.
- Farewell mail: IOffboardFarewellEmailSender.SendAsync returns whether the
provider accepted; the offboarding worker records FarewellEmailSent /
FarewellEmailSkipped / FarewellEmailFailed like the welcome mail — the
send's own success log is Information and never reaches Application
Insights. Missing Email:ApiKey logs Warning. Handler tests per outcome;
OpsAlertRules catalog and the generated manifests (Web + MCP vocabulary)
carry the three types.
- Web: auth/me 403 TenantSuspended no longer shows alert() and forces a
logout. AuthContext keeps the user signed in with claims-only info and
ProtectedRoute renders a suspended page: for the offboarding tombstone
"Your organization is being offboarded" with the farewell feedback form
(a reload during the 6-minute preparation window keeps the form), for any
other suspension the reason text plus contact links; both with sign-out.
The feedback widget moved from OffboardingSection into
components/OffboardingFeedbackForm.tsx.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every tool description and both role instructions now fit the 2048-char host cap
(Claude Code truncated the Global Admin instructions and two descriptions silently).
Pagination, projection and scope mechanics are said once in the instructions and
the shared pageSize/continuation/tenantId/days texts; role markers left tools only
that role can see; get_resource lists its sections on the argument. The SDK $schema
URL is stripped from tools/list.
Measured (compact tools/list): Global Admin 111.4 KB -> 89.1 KB, Reader 94.7 -> 74.3,
tenant user 64.5 -> 51.3, delegated 75.1 -> 55.6 KB. catalog-budget.test.ts pins the
new ceilings, the variant counts and the empty over-cap list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
list_tenants named a real customer domain as its example; the filterTenantList test
carried three real tenants. Both now use synthetic names and GUIDs.
catalog-budget.test.ts pins the per-role wire catalog as a ratchet (size, repeated
argument texts, no foreign domains or GUIDs, the 2048-char host truncation cap with
the four known over-cap texts listed). scripts/audit-catalog.ts dumps and sizes the
catalog per role for before/after measurements.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The XML summary added on TenantManagerItem.Name in ef280b7b rides into
shared-manifests.json as the field's doc; CI run 35701895623 caught the drift.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET delegations/managers no longer exposes the name of an operator-created
Tenant Group to the customer; both operator shapes (groups and direct grants)
carry the neutral label. Assignees, reader count and source stay visible.
Web badge reads "Provisioned by operators". D-267.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
network_state_change and network_connectivity_check join the non-activity
list next to power_state_change. A flapping dock or access point kept the
idle-stopped periodic collectors running for the whole episode and reset
the stall clock of the enrollment it was stalling. The events are emitted
unchanged; the device-info network snapshots stay activity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IME re-runs remediation policies on its own schedule for as long as the
device is up, so an enrollment that waits for its user reported every run
of every policy. The tracker now emits a policy's first run and every run
whose result differs from the last reported one (compliance, exit code,
stderr, remediation status, error code); repeats are counted and surface
as one script_recurrence_summary per policy when the tracker stops.
The start signal of a known policy is held until its result is known,
because the running indicators pair a start with a later result; a start
without any result is still emitted after a grace. The counters live in
the tracker state file next to the file positions, so a hard kill rolls
both back together. Platform scripts are untouched. The summary does not
count as enrollment activity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stored EventCount doubles on replayed upload batches and was only
corrected at terminal transitions, so the sweep judged running sessions on
an inflated number. It now recounts a candidate before it records the ops
event or blocks the device, skips candidates it cannot verify, and limits
the recount to candidates with a pending action. The warn text no longer
claims an agent loop bug.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
73 sites in 68 files built the query collection with
HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed
both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET
Core request type does not override it, so the change is behaviour-neutral.
- QueryParamGuardTests: ParseQueryString in the Functions project is limited to a
named baseline of three sites that parse something other than the request query.
- A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded
values, caching) so a worker update that changes the implementation fails a test.
- Test mocks that set Url answer Query through CallBase; a loose mock returns null.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six functions wrote their responses through a private wrapper taking the body as
object, which no typed-response guard could see. Their error bodies carried the code
in the error slot, no correlation id, and RestoreRow returned the raw exception text
on a 500.
- Backup job status, row restore and backup trigger answer through ResponseHelper:
errors are the envelope, the 500 is sanitized, the 202 body is BackupTriggerResponse.
- Same for the session-deletion maintenance trigger, RestoreSession's three error
sites and the delegation acknowledgements (MessageResponse).
- Backup codes are constants (Constants.BackupErrorCodes) and part of apiErrorCodes;
RunAlreadyActive and EnqueueFailed join ApiErrorCodes.
- TypedResponseGuardTests gains guard F: no HttpResponseData-returning method with an
object parameter. BackupWireParityTests pins that the move from the backup
serializer options to the worker pipeline leaves the wire unchanged.
- Web reads BackupTriggerResponse from the generated wire types.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A version whose installer was already in the archive could only turn
Archived through the 409 on the write-once upload, which needs a
distribution host that still serves the build. For a version no host
serves any more the row stayed Failed:VersionMismatch next to its own
blob and re-walked every host once a day.
The archiver now checks the archive first: an entry whose ProductVersion
is the observed version ends the job as Archived with the hash and size
of the archived bytes and no download; a missing provenance sidecar is
written without a source URL. An entry holding another build, or an
unreadable one, is not accepted and the walk runs as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The browser loads the photo straight from Microsoft Graph with the User.Read
scope already consented at sign-in; it never reaches the backend. Any failure
(no photo, token, network) falls back to the initials. One request per account
and tab, cached in sessionStorage including the no-photo answer.
The four copied avatar blocks in the navbar become one UserAvatar component.
Graph host added to the URL registry and to connect-src in both CSPs; the
privacy page states where the photo comes from and that it is not stored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A process name is not a protected namespace: any user session can keep a
binary named like the agent resident, which made every agent start during
that session exit 1 before registration and let install mode report a
runtime launch that never happened.
The guard and install mode's launch verification now share one rule
(FindSiblingAgentPid): a name match counts only in session 0 and never for
the caller itself. Every runtime start lands there (Scheduled Task, WMI
launch from the SYSTEM install chain, self-update restart), so behaviour
for real agents is unchanged. The session id comes from the process
snapshot, so no handle is opened; a process that cannot be read does not
count, which fails open into two agents rather than none.
Two interactive --console runs in the same user session no longer block
each other; an interactive run next to the session-0 runtime still does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Banner and the success-rate/duration gauges read the evaluation period
(last 30 days) instead of the current ISO week and name the window in their
labels; the app install gauge says "this week". A target whose period holds
no finished enrollment renders a neutral "No data" card and is left out of
the banner count; with nothing to judge the banner is neutral.
Banner logic moved to lib/slaStatus.ts (pure, tested).
Needs the backend of the previous commit: the page reads
evaluationPeriod / evaluationWindowDays.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SLA breach alert judged the calendar month while the dashboard headline
showed the current ISO week, so an alert could report 78 % while the page it
links to showed 0 everywhere - and an empty week rendered as 0.0 % "Breached".
- SlaEvaluationWindow is the single definition of the SLA populations:
success rate and P95 duration on terminal sessions of the last 30 days
(rolling), app installs on the install attempts of the current ISO week.
The breach evaluator and SlaMetricsService both read it, including the
one-decimal rounding, so the value shown is the value judged.
- SlaMetricsResponse carries EvaluationPeriod and EvaluationWindowDays;
SlaSnapshot carries Period and HasData. A period without a finished
enrollment meets every target flag and is marked HasData = false.
- The dashboard reads from whichever starts earlier, the selected months or
the evaluation window; trend and violators stay on the selected months.
- A lasting breach is notified again only after the cooldown AND once
something finished since the last notification. A new breach episode after
a resolve still notifies. Resolve notifications are unchanged.
- Alert period fact and telemetry tag follow the window constant.
- SlaMetricsService takes a nowProvider; the week-boundary workaround in its
tests is gone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configured diagnostics entry is resolved once to folder, pattern and recursion, and
that target is what gets validated and enumerated; recursion asks the guard before
entering a subfolder, and the user-profile token is expanded before validation.
Security, PowerShell and Sysmon event logs are refused per file, by name and by
resolved channel, in every mode; the portal flags such a path before it is saved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Directly under Dashboard the Progress Portal collected misclicks: 418 of 1,050
admin page views in 90 days went on to the dashboard within 5 seconds, from
every origin page. It is the end-user page; regular users keep it as their only
entry via REGULAR_USER_ITEMS. Annotations showed the same walk-through pattern
and moves down with it. Fleet Health, Geographic Perf. and Software lead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
All five cards showed the same bar-chart glyph on a differently coloured
block, so the colour carried no meaning. Each card now has a small grey
outline icon for its metric (pulse, check, stopwatch, calendar, x) in the
top-right corner, and the label gets the freed width. Failed Today turns
its value and icon red only when the count is above zero and reads
"No failures" at zero.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The analyze and gather rule pages loaded the community submissions list for
every role. rules/submissions is TenantAdminOrGA, so an Operator or Viewer got
a 403 and a red "Failed to load your community submissions" banner on every
page visit (16 denials in 24 h from two operators of one tenant).
The pages now skip the list for callers who cannot read it and the
contribution box says that only tenant admins can contribute and follow the
tenant's submissions. Tenant admins, Global Admins and a global-scope override
are unchanged; the backend policy stays as it is.
Bumps the internal and .claude/commands gitlinks (platform-health marker
catalog, backlog).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>