Gather rules and admin-configured diagnostics paths could never reach a
folder below a user profile. The %LOGGED_ON_USER_PROFILE% token lifts the
C:\Users hard block for AppData\Local and AppData\Roaming, but lifting is
not admitting: the path then fell into filePrefixes, which is matched
literally against the already-expanded path, and the profile name is not
known when that list is authored. The exception only ever worked in
unrestricted mode, and no agent test passed a non-null userProfilePath.
D-228 introduces userProfileFilePrefixes, holding entries relative to the
profile root (AppData\Local\RealmJoin). Both guards resolve them against
the detected profile and match segment-bounded; entries that are rooted,
contain "..", or sit outside AppData\Local / AppData\Roaming are dropped at
load, so configuration can only narrow the hard block's exception, never
widen it. The allowlist is anchored to the detected profile, so a rule
cannot reach another user's AppData. The AppData subdirectory list existed
in both guard classes with the GatherRuleGuards copy unused; it is now one
list and DiagnosticsPathGuards delegates. filePrefixes additionally gains
C:\ProgramData\RealmJoin\choco\logs.
D-229 fixes the pre-flight validators, which must only ever reject what the
agent rejects:
- the portal returned "allowed" on the lifted hard block without consulting
the allowlist, so a rule the agent refuses passed the enable gate and then
failed silently on the device with a security_warning;
- the MCP validator offered logparser the diagnosticsPathPrefixes list that
no collector consults (four paths reported allowed that the agent blocks);
- the MCP validator did not expand environment variables at all, reporting
%ProgramData% targets as blocked although the device reads them.
Measured before changing the portal: no rule in any tenant used the token,
so nothing that was working gets disabled.
Verified: solution build 0 warnings, agent 2897 tests, web 1257 + tsc +
eslint 0, MCP 779, guardrails.json validates against its schema, rules/dist
regenerates without drift. The six new agent tests were green against the
unchanged guard first, which is what proves the exception was inert.
Takes effect on devices only after an agent release: guardrails.json is an
EmbeddedResource in the agent assembly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SHA-256 check proves the ZIP is the one version.json names, but that
manifest lives in the same container as the ZIP: it cannot outlive a
compromise of the download host. The expected publisher travels inside this
script, which the loader verified before running it, so the chain from the
file an administrator assigns to the process that starts is publisher-anchored
end to end.
Install-AutopilotMonitor.ps1 v2.4 -> v2.5:
- Get-UnverifiedAgentBinaries checks every AutopilotMonitor.*.exe/.dll in the
extracted payload for status Valid and the same subject pin the loader
carries; the gate runs immediately before --install and throws on any
rejection, exactly like the hash check.
- Third-party assemblies keep their own publisher's signature and are not
checked: System.Management.Automation.dll ships unsigned, so a gate over all
files would refuse the live payload.
- An extracted folder with no binary of ours is a rejection, not a pass.
- The SHA-256 manifest check is unchanged and still runs first: it proves the
bytes, the signature proves the author.
Verified against the live payload 2.0.1454 (all five of our PEs Valid and
glueckkanja AG, gate passes); appending one byte to AutopilotMonitor.Shared.dll
makes it reject. Pester 55/55.
The portal privacy and about pages carried the pre-signing wording and are
corrected in the same change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quiet third line in the panel header ("Latest agent version 2.0.1452",
faint, the number monospaced) reads from the existing config/latest-versions
endpoint. The monospaced string is the same one the session list shows in its
"Agent Version" column, so a reader can tell at a glance whether their fleet
runs the latest agent.
Portal and signed-in only: the endpoint is AuthenticatedUser, and the public
site has no session list to compare against. The gate sits on the mount of the
small component, so the landing page never even issues the request; a missing
version renders nothing.
The portal showed the two-stage bootstrap output ending in ...[truncated]. That marker
comes from the agent, not from the web view: the stdout is cut at capture time, so no
amount of UI space brings the rest back.
Measured over 1,486 script outputs in the fleet (2026-08-25..09-08): median 39, p90 644,
max 1,501 chars, and the 2,048 cap had never been hit -- the maximum was our own
one-stage bootstrap. The loader preamble of ~460 chars pushed exactly that output over
the edge, i.e. this is a consequence of D-224 and it hit the one output an operator
actually reads.
8,192 covers the realistic worst case (download retries plus relax logging, ~2,700) and
stays 4x below the 32K-char Table Storage property limit that bounds the event
downstream. Other scripts are unaffected in practice: p90 is 644 chars.
Reaches the fleet with the next agent release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bootstrap-loader.log is new with the two-stage bootstrap and told the diag tooling
nothing so far. Names which channel a device was assigned: the loader logs there when
assigned directly, and to bootstrap-msi.log when it runs from the MDM-LOB MSI.
Takes effect with the next MCP deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Invoke-BootstrapDownload.ps1 and Start-AutopilotMonitor.ps1 had become the same script:
download the current installer, run it, log, always exit 0. The MSI copy additionally
downloaded and executed WITHOUT verifying what it got, which is exactly the gap the loader
was written to close. The MSI is now packaging only -- it installs and runs the same file
that is assigned directly in Intune, and both delivery channels get publisher verification
from one implementation.
Dropped from the MSI path: its own Deployed-marker check. That guard lives in stage 2 and
runs there anyway, so the only cost is one download on a device that is already deployed.
Kept: the log file name. bootstrap-msi.log is what the diagnostics tooling keys on to tell
the MDM-LOB channel apart from the Intune script channel, so the loader takes -LogFileName
(default bootstrap-loader.log) and the custom action passes bootstrap-msi.log. A Pester
test now pins that contract across the two files -- renaming the parameter on either side
would leave the MSI channel logging under the wrong name.
Package version 1.0.2 -> 1.1.0 so Intune LOB update detection sees the new payload, and a
new component GUID because the key path file changed.
Loader 1.0 -> 1.1. Bootstrap suite on Windows PowerShell 5.1: 44/44.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/ at the repo root is the customer surface: everything under
Bootstrap/ and CustomerSetup/ is published to the download container by
Publish-BootstrapScripts.ps1, Authenticode-signed and version-gated. The
error-code generator is a maintainer tool no workflow runs, so it diluted
that invariant. .github/scripts/ was not the alternative -- it holds only
helpers a workflow step invokes, and unlike guardrails-in-sync and
shared-manifests-in-sync the catalog has no CI drift gate (the generator
needs IME_ENUMS_JSON from operator tooling and live MS Learn fetches).
REPO_ROOT stays ../.. at the new depth, so only the documented path moved:
the generator header and README, the C# and TypeScript comments, the test
summary, and the description header of error-codes.json together with the
generator literal that emits it (the two must stay identical or the next
regeneration diffs). The web sync re-ran clean at 607 entries with no
change to utils/error-codes.json, which drops the header.
Title and trailing controls shared one non-wrapping flex row with the controls
marked flex-shrink-0. At 440px (iPhone 16 Pro Max) that squeezed the Operational
Events title into an 86px column and pushed the Refresh buttons of Agent Metrics
and the Tenant Config Report past the viewport, scrolling the whole page sideways.
SectionCardHeader now stacks below sm, which covers all 32 cards that use it.
A width sweep over the portal routes at 440px found two more real overflows, fixed
here as well: the audit From/To date pair, and Alert Rules, where the unbreakable
event-type names and a channel-name input without min-w-0 pushed the severity
selects 99px off screen.
Verified: every swept route now measures zero horizontal overflow at 440px, desktop
is unchanged at 1440px. The header test asserted "no trailing group" via the absence
of gap-3, which the wrapper itself now carries; it checks flex-wrap instead and pins
the stacking classes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card composed its line from the backend's explanation ("did not answer within
3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a
starting instance needs. It now writes its own one-liner and ignores check.message,
so the server's "warming" and a client-side probe timeout read identically and the
wording does not depend on a backend deploy. The backend message is shortened but
still carries the budget for direct API callers.
The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps
activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test
pinning that the stated seconds come from cadence x attempts.
Measured over 14 days, GET health/mcp answered in 4..148ms when warm and timed out at 30s
in 50 of 83 calls — the median call took 27,972ms. A cold start is 20-30s of Container Apps
activation (~98% KEDA plus image mount, ~500ms application), so no UI wait covers it and the
generous timeout only bought a long wall in front of the same answer. The browser's own
budget is the same 30s, so the backend's carefully worded warning never reached the user at
all: they saw a raw DOMException.
Backend: the budget drops to 3s, which sits in the widest observed latency gap
(2347ms..3930ms) so jitter cannot flip the verdict. Our own cancellation now reports the new
status "warming" at Information level (with minReplicas=0 a cold probe is the expected path,
and logging it as a warning poisons the ops signal); HttpRequestException is split out of
that catch and stays a real warning, still without the host in the message. The warm-hit
threshold drops to 750ms — ~5x the warm maximum, deliberately generous because every
measurement comes from a near-idle single-user system and warm latency will rise under
concurrency — and moves into McpReachableMessage so a test can pin the boundary. The XML doc
now describes the probe that exists rather than the wake-signal design the measurements
disproved.
"warming" is a new status value rather than an added DTO boolean because the backend deploys
before the web: an unknown value degrades to a grey card with a clean banner, whereas a
boolean would leave old web showing yellow and "Warnings detected" on every cold start.
HealthCheck.Status now documents the five-value vocabulary, which rides into both generated
TypeScript mirrors as JSDoc.
Web: resolveMcpCardState owns display, banner rating and the poll decision as a pure
function (the suite runs on node, so testable logic cannot live in JSX). A warming server
re-probes every 5s for up to 8 attempts and stays out of the overall banner exactly as
"unknown" already does — scaling from zero is expected behaviour, not a degraded platform —
and only becomes a real warning once the re-polls are spent. The card keeps showing
"warming" while the next probe is in flight so it cannot flicker, carries the blue
in-progress look instead of yellow, and was hoisted out of the healthResult guard: a failing
detailed check would otherwise hide a card that is still polling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column showed high/medium/low under the header "Identification", which reads as a method rather than a rating. The wire field is `confidence` and the tooltip already says "identification confidence"; the header and legend now match. The analyzer toggle and its hover border were still emerald from before the section moved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The portal hides Delete for owned (msp-{home}) groups, but DELETE global/tenant-groups/{groupId} accepts any id: a Global Admin deleting one ended Pro (MSP) for every member with no grace anchor, so a member that raised its retention under Pro was clamped to the Community cap at the next sweep. DeleteGroupCoreAsync now snapshots the owner and calls ProConferralService.RecordLossAsync(member, owner, "group-deleted") per member, owner excluded, independent of assignees. Two tests in RevokeEnforcementTests; internal docs list the fourth writer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Act equals the KEV set by construction (CvePriority: KEV => act), so the KEV tile becomes the Act tile and Attend joins it instead of a duplicate count. The breakdown is counted in the backend over every CVE in the window, like the severity breakdown, because the list is top-N. Both panel consumers read the generated CveExposureSummary type; the MCP tool description names the new block. Wire types regenerated for web and MCP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The caller-supplied X-MCP-Tool-Name header went verbatim into the UserUsageLog
row key ({yyyyMMdd}_{tool}:{endpoint}) and from there into the CAS-conflict
warning of TableCasRetry. EndpointNormalizer.ToolNameKey now strips CR/LF and
every other control character or line separator, replaces the characters Table
Storage rejects in keys (/ \ # ?) and caps the length at 64; an empty result
skips the prefix. Real tool names pass through unchanged, so existing usage
rows keep their keys. A header with an illegal key character previously made
every usage increment fail with 400 (fail-soft, counter never advanced).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The manifest committed with d5d1d7bf carried the types section of the previous HEAD, so
the ntpServer doc string still read "null = use agent default"; SharedManifestParityTests
compares byte-for-byte. Generated output only, no wire change. Bumps the internal gitlink
(plan review addendum).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report still carried two hand copies after D-215: a typed map of C#
initializer defaults for the "custom" highlight, and a TypeScript mirror of
GetAgentConfigFunction's resolution rules for the runtime column.
Backend: the derivation moves verbatim into Services/AgentConfigResolver
(Build = pure, ResolveAsync loads tenant + admin config and the two rule
catalogs). GetAgentConfigFunction keeps the kill verdict, the migrate logging
and the device fields on top; ParseAgentMajor, ResolveDiagnosticsUploadEnabled,
MergeDiagnosticsLogPaths and ResolveMigrateTarget move with it (call sites and
tests retargeted; the QueryParams ratchet baseline shrinks by the file). New
GlobalAdminOnly route config/{tenantId}/effective-agent-config returns the same
AgentConfigResponse without a device (optional ?agentVersion=, default the V2
line). AgentConfigResolverTests pin the rules that used to live in the web:
upload 10, Hosted destination without SAS, SAS regardless of mode, unrestricted
only with entitlement + gate + toggle, operator knobs from AdminConfiguration,
analyzer defaults, hash line per major, migrate override and rejection.
Manifest: SharedManifestParityTests emits "defaults" next to "fields" for
TenantConfiguration and AdminConfiguration and an "agentConfig" section with
the AgentConfigResponse / CollectorConfiguration / AnalyzerConfiguration class
defaults (fresh-instance values; nested objects null). TenantConfiguration.
NtpServer gets the initializer "time.windows.com" the table read already
applies, so the manifest default and a stored row agree.
Web: the catalog rows lose value() and default; the runtime column renders
the endpoint's response, the highlight compares against the manifest defaults,
informational rows (reasons, timestamps, identities, secrets) never read as
custom. Previously excluded fields are visible (configVersion, catalog counts,
sealing pattern ids, hash oracle, migrate target); the collector class
defaults sit in a collapsed "Collector Internals" section. Fail-soft: while the
backend is not deployed the runtime column shows a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Health by Device Model" link searched "{Manufacturer} {Model}" as one substring; the server's
free-text predicate tested it per field, so once the failed sessions were outside the loaded first
page the sweep returned nothing.
- lib/searchQueryTokens.ts: tokenizer shared by the timeline and the session search (terms, quotes,
leading minus, key=value / key:value qualifier when the separator precedes any quote)
- app/dashboard/utils/sessionSearchQuery.ts: 15 qualifiers = exactly the fields both sides search,
matcher over session fields + client-derived extra text, suggestion field lookup, link builder
- useDashboardFilters / SessionTable: filter and suggestions use the grammar; fuzzy only for a
single free term; session_searched gains a `qualifiers` count
- Helpers/SessionSearchQuery.cs: the same grammar for q= on /api/search/sessions (+ global), parsed
once per request; MatchesFreeText is a thin wrapper
- utils/session-search-syntax.cases.json: one case file run by vitest and by
SessionFreeTextSearchTests (parity proof)
- FleetModelHealth/FleetSlowModel/FleetFailingModel: manufacturer + model as stored plus label,
grouped on the pair; useFleetHealth uses the generated wire types; "Unknown" is not a link
- shared manifest + wire types regenerated for the three DTOs
Backend suite 6158/6159 (the one failure is SharedManifestParityTests against uncommitted WIP of a
parallel task), web vitest 1177/1177, build 0 warnings.
The progress portal placed the active step purely by the stored phase. After
whiteglove_complete the backend caps that phase at Apps (Device), so the end
user saw "Waiting for you to sign in" above a step list that still showed
"Installing apps (device)" as running at 43 % (customer report).
The seal detection the headline already used is now one helper,
isParkedAfterTechnicianPart (pre-provisioned, whiteglove_complete, no resumedAt
and no whiteglove_resumed), and resolveActiveStepIndex places such a session on
the first user step: every device step done, 57 %, Account setup current. A
stale Complete phase on older sessions can no longer push it past the sign-in;
once resumed the placement is phase-driven again.
Vitest 24/24, tsc and eslint clean. Backlog B-ydz carries the post-deploy check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report typed its own TenantConfig interface and drifted for five months: 31 stored
fields were missing (payingCustomer, the MSP overrides, three validation gates, the SLA
block, four analyzer flags, app-homing and trial bookkeeping) and three runtime values
were wrong (upload interval 30 instead of 10, diagnostics-enabled without the Hosted
destination, unrestricted mode without the entitlement).
tenantConfigReportCatalog.ts is Record<keyof TenantConfiguration> and
Record<keyof AgentConfigResponse | CollectorConfiguration | AnalyzerConfiguration>:
every key is rendered or excluded with a reason, so a new C# property fails tsc once the
wire type is regenerated. Runtime rows carry the GetAgentConfigFunction rule and a source
tag; entitlement values come from feature-flags, operator knobs from global/config.
A vitest pins the same parity against SHARED_MANIFEST.tenantConfiguration.fields and the
resolution rules (upload 10, Hosted destination, flags-driven unrestricted mode, admin
idle timeout, analyzer defaults). Empty strings count as unset for the custom highlight.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Parallel tool calls of one user hit the same (user, day, endpoint) row in the same instant; without backoff the losers stayed synchronised and 3 of 6 increments were lost. Both increments now use the one CAS helper (merge patch, AddEntity on 404, StorageCasConflict marker) with UsageCounterRetries = 8; the tenant counter's attribution columns travel in the merge patch. Tests pin the budget, the patch shape and the create path.
- IME-EXITCODE-CLASS captures the return-code class of the installer exit code ("lpExitCode is defined as <Success|SoftReboot|HardReboot|Retry|Failed>", verified against the IME string literal) as exitCodeClass on AppPackageState; new action captureExitCodeClass, persisted across the agent restart a reboot causes, last value wins across a Retry loop. IME-EXITCODE-MAPPED-FAILED keeps the error path; both match the same line.
- Adapter: exitCodeClass whenever known, exitCode on every terminal state (Installed/Postponed/Skipped/Error) instead of Error only.
- ANALYZE-APP-018 (warning, apps): app_install_completed.exitCodeClass in SoftReboot,HardReboot; +25 HardReboot, +15 observed system_reboot_detected; never fails the session.
- Web: completed-app exit badge amber (failed stays red), "mapped as SoftReboot/HardReboot/Retry" on timeline and Install Progress.
- Tests: pack end to end (phase, current app, class lines, negatives, restart round trip), adapter payloads, linearity samples, backend rule engine + known actions.
- internal gitlink: plan, D-214, backlog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCP: coverage block (window, IME tracker, collectors, upload, diagnostics, gaps) built from the
health events the summary already fetches (session-coverage.ts, data.<key> slices); validate_rule
warns on a required not_exists of an IME-log-derived event without the ime_tracker_degraded
precondition; authoring guide 'absence'; instructions and prompts point at coverage.gaps.
Rules: guardrails.json imeLogDerivedEventTypes (schema + combine). Backend: catalog invariant test —
absence rules carry the coverage gate or a documented exception (ANALYZE-ID-004).
Agent: diagnostics_uploaded/_upload_failed carry packaging counters (truncated, includedFiles,
skippedFiles, skippedByReason, problemsByKind) — counts only, never paths.
Also: shared-manifests.json regenerated (was stale at HEAD; McpAccessPolicy doc string) + codegen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Order at the end of the modal: MCP Access, Tenant Suspension, Offboard Tenant. Rarely used
levers sit together; the frequently edited plan, contact and limit sections come first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TenantConfiguration.McpDisabled + McpDisabledReason (GA-only, same deny-list and PUT guard as Disabled).
Front door: McpUserService denies every caller of a closed home tenant below the platform roles.
Per request: PolicyEnforcementMiddleware denies a closed home or target tenant for X-Client-Source: mcp
(403 McpDisabled) and narrows the delegated fleet bound, never to null. ClientSourceHeader is the one
reader of the header. The MCP access guard surfaces the backend reason verbatim. GA tenant modal,
list badge and config report; wire types regenerated; internal doc, health marker, gitlinks bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A fixed overlay still receives its parent's box-model margins: the tenants page's
space-y-8 wrapper gave the edit dialog's backdrop a 32 px margin-top, leaving the
navbar un-dimmed. All 21 full-screen overlays now portal to document.body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.
What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.
Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The eight backend POST/PUT/PATCH bodies name their request DTO through
jsonBody<T> from client.ts — the one place a body is encoded — so a renamed
or retyped backend field fails tsc instead of surfacing as a 400 at runtime.
The apiFetch guard additionally refuses `body: JSON.stringify(` and a bare
`jsonBody(` with an empty baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).
Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).
35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).
TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The surface comes from the signed appidacr/azpacr claim (0 = public client = portal;
secret/certificate, app-only or missing = integration), never from a header: portal SPA,
MCP server and API share one app registration in production, so the client id cannot tell
them apart and any token holder controls headers. New AdminConfiguration field
PortalUserRateLimitRequestsPerMinute (default 600, Global Settings round-trip, wire types
regenerated); the existing user budget is relabelled MCP & integrations and keeps the tenant
override and the Pro floor; buckets are keyed per surface; ThrottleSurface is a request
dimension next to ClientSource so the classification is auditable (platform-health check 2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A freshly onboarded tenant spent 45 minutes in 503 because a second instance held a
roleless app-only token minted before the consent; InvalidateTenant never reaches other
instances. GraphAuthFailure is the shared step for all five Graph validators: attempt 1
drops the cached token and reports transient so the existing loop re-mints; attempt 2
keeps each validator's semantics (Autopilot/DeviceAssociation transient with Retry-After
120, CorporateIdentifier/CloudPc/IntuneBinding definitive) and logs a stable marker.
SecurityValidator hands the agent the largest Retry-After requested. Platform-health
check 6 and the marker catalog know both markers; the cloudpc concept doc carries the
contract.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BackupJobStatus, RestoreRowPreviewResponse and RestoreRowCommitResponse were the last response payloads the
portal typed by hand: the C# classes existed in Shared but were not marked [WireContract], so the manifest
never carried them. The attribute pulls them and their enums (BackupJobKind/State, BackupOutcome,
RestoreRowDiffKind, RestoreRowCommitOutcome) into shared-manifests.json and both generated wire-type files;
the three backup pages import those and lib/api.ts drops eleven hand-written mirrors. Attribute only: no
runtime behaviour changes, no backend deploy needed.
Still hand-written in lib/api.ts, with reason: BackupManifest/BackupTableEntry (a blob passed through by
GET global/backups/{id}), BackupTriggerResponse (the 202 body of global/backups/trigger is an anonymous
object outside the typed regime) and the RestoreRow request body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five hand-written customs-archive response types in lib/api.ts duplicated CustomsArchiveRunListResponse,
CustomsArchiveEntryListResponse, CustomsArchiveRunSummary, CustomsArchiveEntrySummary and
TenantOffboardingCustomsArchiveEntry from the manifest; the two pages import those. The annotations page
built its next-page URL from API_BASE_URL + nextLink instead of the scoped builder plus extractContinuation.
PaginatedResponse<T> had no importer. types/diagnostics.ts states why it stays hand-written (no wire DTO
for GET diagnostics/paths).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rules pages, apps tabs, audit, delegations, diagnosis, fleet health, geographic, health check, SLA, usage
metrics, the notification and SignalR contexts, the feedback bubble, global search and the shared hooks
now call fetchJson/fetchOk; the activation page's hand-rolled Bearer fetch (no 401 retry, no
telemetry) joins the layer too. The SignalR join/leave paths keep their visible-refusal semantics through
ApiError, the delegation acceptance explains refusals by code, the health check keeps its 403 wording,
and the local mirrors of SlaMetricsResponse and PlatformUsageMetrics give way to the generated types.
No page, component, hook or context calls authenticatedFetch directly any more. What stays is
documented per file in the baseline's PERMANENT map: the auth/me bootstrap, the SWA's own version.json,
the public stats manifest, the delete-cascade classifier's 202 body, and seven TokenExpiredError
branches where the UI must act differently from showing the message (route, rethrow, batch, retry flag).
Ratchet baseline: 39 → 0 raw fetches, 40 → 7 parses, 25 → 8 token checks (from 228 / 221 / 140).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
About 12 MB/day of severityLevel-1 traces without diagnostic value (MSAL verbose, StatusCodeResult,
CORS success, Application started). Application logs were Warning+ already via Function: Warning.
Measured and documented in internal/docs/backend/telemetry-ingest-shaping.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 33 raw authenticatedFetch sites under app/sessions, app/dashboard and app/progress are gone. Hooks
that receive addNotification as a parameter report failures through notifyApiError (same rendering as
notifyError, injected notifier); fail-soft reads that used to check res.ok read through
.catch(nullOnApiError). The delete-cascade classifier accepts the ApiError of a refused DELETE next to
the 202 Response, so the 409/503/404 branches keep their wording; the polling probe reads the status
from either. Session and event reads name their wire types (GetSessionResponse, GetSessionEventsResponse,
SearchSessionsResponse, SessionStatsResponse, the progress and rule-stats responses); the legacy
list-shaped fallbacks on the single-session read are gone. SessionTimeBreakdownDto is the generated type.
Ratchet baseline: 72 → 39 raw fetches, 67 → 40 parses, 36 → 25 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 46 raw authenticatedFetch sites under app/settings are gone: 20 by the transformer, the rest by hand.
The tenant config load keeps its 403 fallback to the feature-flags view through ApiError; the access
check treats a backend refusal as an outcome and only a failed round trip as transient; delegated
access, MCP users and the offboarding flows read their specialised envelopes through ApiError.code
and ApiError.body. Twenty 'Session Expired toast / else inline' catch dialects collapse to the inline
message apiErrorText already renders; local mirrors of McpUserEntry, PlanTierDefinition,
HardwareRejectedResponse and DeviceNotRegisteredResponse give way to the generated types.
Ratchet baseline: 115 → 72 raw fetches, 111 → 67 parses, 59 → 36 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- StorageClientOptions: Table/Queue 8 s per attempt × 3 attempts + capped backoffs = 25.8 s < the
agent's 30 s upload timeout (pinned by test); Blob 60 s × 4. Every production client takes its
options from there. No Polly over the SDK.
- StorageErrors.IsTransient(Exception): OperationCanceled / IO / HttpRequest / Aggregate-of-those
/ RequestFailed status 0; the ingest answers 503 + Retry-After instead of 500.
- The ingest still takes no caller cancellation (a batch is a unit). GetAllSessionsPageAsync
carries the request token down to BoundedFanOut (Global Admin list); SingleFlightCache hands its
factory a 30 s budget token and evicts on timeout (tenant ids, app-summary snapshot, session stats).
- ProcessStallMonitor: 1 s tick drift ≥ 1 s ⇒ ProcessStall event (sampling bypass) + metric with
cause gc / threadpool / unknown from GC.GetTotalPauseDuration and thread-pool counters;
GcPauseMs on request rows ≥ 100 ms. Measures the instance-local 5–10 s pauses seen in production.
- Gitlinks: internal (plan, D-203, concept doc, B-9hf, lesson), .claude/commands (marker rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>