Make the fleet cards navigable into a per-tenant read-only overview at
/fleet/[tenantId]: header, stat tiles (active / sessions / failed / terminal-
only success rate) and a recent-session list. The tenant is an explicit route
param, so every read is a single-tenant /global/*?tenantId= call the backend
bounds to the caller's delegated scope (GlobalReadOrDelegatedSubset) - there is
no all-tenants path. As defense in depth the page refuses a tenantId the caller
does not manage (unless full platform scope) BEFORE issuing any fetch.
- app/fleet/[tenantId]/page.tsx: guarded drill-in; reuses useFleetSummaries for
the stat tiles (single-tenant) and a new useTenantSessions hook for the list.
- app/fleet/[tenantId]/hooks/useTenantSessions.ts: first-page session fetch via
globalSessions.list(tenantId); fail-soft, em-dash on error.
- components/SessionStatusBadge.tsx + utils/sessionFormat.ts: shared read-only
presentational primitives (new code consumes them; the existing inline copies
in dashboard/geographic are left untouched this phase - migration is a tidy-up
follow-up).
- app/fleet/page.tsx: FleetCard is now a Link to /fleet/[tenantId].
Deep session-detail drill-in is intentionally out of scope: it needs a
/api/global/sessions/{id} (+ /events) endpoint, since the tenant-scoped
/api/sessions/{id} detail path is MemberRead-only and unreachable for a
delegated, non-member caller. Rows are non-navigable until then.
tsc clean; 481/481 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex finding: the per-tenant cards show the backend success rate,
which is terminal-only (succeeded / succeeded+failed). The fleet roll-up instead
divided by total sessions (which includes active/pending), so the tile and the
cards disagreed — e.g. a tenant with 90 succeeded / 10 failed / 900 active shows
90% on its card but the roll-up showed 9%.
- computeFleetRollup: success rate now sum(succeeded) / (sum(succeeded)+sum(failed)),
excluding active/pending from the denominator — same definition as DashboardStats.
- successTone: red emphasis now gates on terminal-session count (not total), so a
tenant with only active sessions reads 0% but isn't flagged as failing.
- Tests: replaced the total-based weighted test with terminal-only weighting and
added an explicit 90/10/900 case locking in 90% (not 9%).
Web tsc clean; 481 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enriches the fleet grid with live health, so an MSP admin can triage "which of my
customers is burning" at a glance.
- useFleetSummaries: bounded (concurrency 6) subset fan-out over the Phase-2a
single-tenant /global/stats/sessions?tenantId= endpoint — one server-aggregated
summary per managed tenant, collected into a map. Client-orchestrated over the
already-bounded per-tenant reads; one tenant's failure never sinks the view.
- fleetRollup: pure, testable roll-up. Success rate is WEIGHTED by session volume
(sum succeeded / sum total), not an average of per-tenant rates. 4 unit tests.
- page: roll-up tiles (tenants / active / failed / weighted success) + per-tenant
cards showing active, failed, success%. Cards are triage-ordered (most failures
first; loading tenants last) and use red emphasis on failures / sub-80% success.
Web tsc clean; 480 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex finding: Phase 3b made delegated-only MSP users non-regular (so
they get the group nav), which also rendered the Dashboard link for them — but
/dashboard classifies the same user as regular and bounced them to /progress. So
clicking Dashboard sent an MSP admin to the end-user progress portal.
Fixed on both ends:
- GlobalSidebar: gate the Dashboard link on isAdminOrOperator || hasGlobalScope
(genuine own-tenant/platform users), not merely !isRegularUser. A delegated-only
admin has no own-tenant stake, so the link is hidden; their entry point is the
Fleet group.
- /dashboard: a delegated-only user who reaches it by deep link now redirects to
/fleet instead of /progress, consistent with the AuthGate landing.
Web tsc clean; 476 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives a delegated ("MSP") admin a place to land and see the tenants they manage.
- ProtectedRoute: new requireFleetScope (full platform scope OR delegated). The
backend still enforces the per-tenant bound on every request; this just keeps a
single-tenant user off the fleet route.
- Nav: a "Fleet" group (Managed Tenants -> /fleet) with a new "fleet" visibility,
shown to a delegated admin who lacks full platform scope (a GA/Reader manages
tenants through the richer Global Admin section, so it stays hidden for them).
GlobalSidebar's isRegularUser now accounts for fleet scope, so a delegated user
gets the group-filtered nav instead of the minimal regular-user list.
- AuthGate: a delegated-only user (no own-tenant/platform role) lands on /fleet.
- app/fleet: layout (requireFleetScope) + landing page rendering the managed-tenant
grid. The list comes from config/all (backend-bounded to the caller's subset,
Phase 2b) and is additionally intersected with the JWT delegatedTenantIds as
client-side defense in depth.
Cards are read-only overview this phase; per-tenant health (3c) and drill-in (3d)
build on this. Web tsc clean; 476 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses two linked Codex findings created by the Phase-3a preview-gate bypass.
(1) HIGH — auto-admin escalation: the first-login auto-admin rule excluded only
GlobalReader, not delegated callers. An external MSP user delegated to manage
tenant B, logging into a home tenant A with no existing admins, would be silently
written as Tenant Admin of A — converting a read-only delegated assignment into
write authority over the MSP home tenant. Fix: needsAutoAdmin also excludes
delegated callers (!isDelegated). A delegated user who legitimately administers
their own tenant has a TenantAdmins row, so this only suppresses the silent grant.
Regression test added (delegatedTenantIds non-empty, memberRole null,
hasTenantAdmins false → NeedsAutoAdmin false, isTenantAdmin false, role null).
(2) side-effect class: /auth/me read the home-tenant config via the auto-creating
GetConfigurationAsync and then ran first-login onboarding side-effects (domain /
OnboardedBy write, "new tenant signup" Telegram, auto-re-enable). For a
delegated-only external MSP login this phantom-onboarded the home tenant. Fix:
read via the non-creating TryGetConfigurationAsync, and run the onboarding
side-effects only for a genuine home-tenant participant (not delegated-only, OR
global, OR an existing member). Genuine first-users still onboard normally.
Full Functions suite green (2473).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the web MSP/fleet experience: the app now knows whether the signed-in
user is a delegated admin and which tenants they manage.
Backend (/api/auth/me):
- Resolve the caller's delegated scope (cached) alongside the existing parallel
fetches and return delegatedTenantIds (the managed tenant set) + isDelegated.
- Delegated admins bypass the private-preview gate, exactly like Global Admin /
Reader — an MSP whose own home tenant is not on the waitlist must still be able to
log in and manage its authorized tenants. Gate 1 (suspended home tenant) still
takes precedence. BuildAuthResult gains an optional delegatedTenantIds param
(existing call sites unaffected); 3 new tests + the side-effect harness updated.
Web (AuthContext):
- UserInfo gains isDelegated + delegatedTenantIds; AuthContextType gains
hasFleetScope = hasGlobalScope || isDelegated for fleet/switcher gating. All
/auth/me parse paths (success, prefetch, preview/error fallbacks) populate them.
Full backend suite green (2472); web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The $profilePaths assignment wrapped only the inner subexpression in @(),
so the trailing Where/Select-Object -Unique pipeline unwrapped a single
result back to a scalar string. With exactly one user profile present,
$profilePaths[0] then indexed into the string and returned its first
char ('C'), making Get-Item fail with "Cannot find path 'C'". The OOBE
age check crashed and the Windows Backup for Organizations restore case
never relaxed guards 2+3.
Force an outer @() so $profilePaths is always an array. Also surface the
profile age and the exact failing condition in the SKIP/NOT-applied logs
for diagnosing future OOBE attempts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex finding: no handler-level coverage existed for the delegated
config/all filtering/redaction path (coverage was policy-context + projection only).
Following the repo convention (handler HTTP entry not unit-tested — see
GetAllBlockedDevicesFunctionTests), the security-relevant transform is extracted
into two pure, testable seams on GetAllTenantConfigurationsFunction and tested
directly:
- ExistingManagedConfigs: from the per-tenant point-read results, keep only managed
tenants that actually have a config row (exists==true); drop the rest. Tested:
drops missing rows, keeps present ones, empty when none exist.
- DelegatedBareArrayView: redact every managed config's secrets (a delegated admin
is never a Global Admin). Tested: secrets -> placeholder, non-secrets preserved,
original instance not mutated.
5 new tests; full Functions suite green (2469).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex review finding: the Phase 2b delegated config/all path loaded
EVERY tenant config via GetAllConfigurationsAsync() (an O(all tenants) table scan
+ full-config materialization) and then filtered in memory. Because Phase 2b
opened this endpoint to delegated MSP users, a caller scoped to a few tenants
could trigger an all-tenants scan per request — response-bounded, not read-bounded.
Now point-read ONLY the caller's managed tenants via the non-creating
TryGetConfigurationAsync (cached; never writes a phantom default row), dropping
ids with no config row (exists==false). A delegated MSP scoped to k tenants does
k point reads instead of scanning n. The response is still filtered+redacted as
defense in depth; the set fed to it is now itself bounded.
Full Functions suite green (2464).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces the GlobalReadOrDelegatedSubset policy tier — a cross-tenant READ that
is BOUNDABLE to a subset — and applies it to config/all (the tenant list). A
delegated ("MSP") admin can now list ITS managed tenants in one call, bounded to
its allowed set; a Global Admin / Reader still gets all tenants (unbounded).
This is the server-side linchpin for the MSP fleet view + tenant switcher: the
frontend gets the managed-tenant list (id + domain) here, then builds the
per-tenant fleet grid by fanning out over that subset using the Phase-2a
single-tenant /api/global/*?tenantId= endpoints (subset fan-out, client-orchestrated
over already-bounded per-tenant reads — no all-tenants code path for delegated).
- Middleware: new EvaluateGlobalReadOrDelegatedSubsetAsync — GA/Reader admitted
unbounded; a delegated caller with a non-empty scope admitted with its managed
set carried on the decision and published as RequestContext.AllowedTenantIds.
DecideAsync merges this into the delegated flags + AllowedTenantIds.
- GetAllTenantConfigurationsFunction: when AllowedTenantIds is present, BIND the
response to that subset (both the bare-array and paginated shapes) and always
redact secrets (a delegated caller is never a Global Admin). The subset is small,
so it is returned in one shot.
- Catalog: config/all -> GlobalReadOrDelegatedSubset. Aggregates that cannot be
bounded stay GlobalReadOrAdmin + None (delegated-unreachable).
- Tests: delegated config/all admitted with bounded AllowedTenantIds; GA unbounded
(null bound); non-delegated non-global denied; tier-assignment guard. Full suite green (2464).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a delegated ("MSP") admin read its managed tenants through the cross-tenant
/api/global/* endpoints — but ONLY on the single-tenant ?tenantId=X path, bounded
by the middleware to a tenant in its allowed set. This is what makes the tenant
SWITCHER work end-to-end (drill into a managed tenant, see its normal dashboard).
The dangerous no-tenantId AGGREGATE path (fan-out over ALL tenants) stays
GA/Reader-only and is untouched here; bounded fleet aggregation is a separate step.
- Middleware: GlobalReadOrAdmin added to the delegated read tier. The existing
`isScopedRoute && crossTenant` guard means a delegated grant still fires ONLY on
a tenant-scoped route for an in-scope target — so a delegated caller reaches a
global endpoint only via its single-tenant path, never the aggregate path.
- Catalog: the 27 GlobalReadOrAdmin routes whose handlers strictly restrict to the
named ?tenantId= (verified by an exhaustive per-endpoint audit) recataloged
None -> QueryParam. QueryParam/RouteParam on a GlobalReadOrAdmin route now
formally means "handler restricts to the named tenant => delegated-reachable";
aggregate-only routes (presence, platform metrics, distress-reports, config/all,
…) stay None and remain unreachable by delegated callers. Documented inline.
- Tests: delegated single-tenant global read allowed in-scope / denied out-of-scope
/ denied on the no-tenantId aggregate path; an aggregate-only route stays blocked
even with an in-scope ?tenantId=; Global Reader aggregate path regression; and a
catalog-contract theory pinning the SAFE(QueryParam) vs AGGREGATE-ONLY(None) split.
Updated GlobalAppsRoutes completeness assertion to QueryParam. Full suite green (2461).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex review finding: the normal policy evaluator runs before the
delegated rescue, so an external MSP user's first delegated cross-tenant read
still touches the own-home-tenant role path. That path called
TenantConfigurationService.GetConfigurationAsync, which AUTO-CREATES and persists
a default TenantConfiguration row for a missing tenant — writing a phantom config
row for the MSP's (possibly non-onboarded) home tenant.
Authorization role resolution must be side-effect-free. ResolveEffectiveRoleAsync
now uses the existing non-creating TryGetConfigurationAsync (returns the in-memory
default without persisting). A missing config means EntraAppRolesEnabled = false
either way, so the resolved role is unchanged — only the phantom write is gone.
This also removes the same latent write for ANY authenticated non-member.
Regression test: "delegated read does not create config for MSP home tenant"
(verifies SaveTenantConfigurationAsync is never called). Full Functions suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the delegated ("scoped global" / MSP) tier into the auth middleware. A
delegated admin signs into their OWN home tenant but may now READ a SUBSET of
tenants — exactly the tenants in its DelegatedAdmins scope — without holding
full platform (GlobalAdmin/GlobalReader) scope.
Mechanism (PolicyEnforcementMiddleware.DecideAsync):
- Resolve the target tenant up-front (ResolveTarget), then resolve delegated
scope LAZILY — only when the caller lacks full platform scope AND the route is
a cross-tenant, tenant-scoped route. Keeps the common authenticated path free
of the extra (cached) table read. Service errors still fail closed to 503.
- A delegated grant applies ONLY to read tiers {MemberRead, TenantAdminOrGlobalReader}
on RouteParam/QueryParam routes, and ONLY for a target in the caller's scope.
It rescues a tier-denial for a delegated-only caller and lifts the cross-tenant
block for that target. Write tiers never admit a delegated caller (even a
DelegatedAdmin row grants read-only this phase), so no read->write escalation.
- Aggregate /api/global/* endpoints (GlobalReadOrAdmin) fan out over ALL tenants
and are deliberately NOT reachable by delegated callers until Phase 2 bounds
them to AllowedTenantIds.
RequestContext gains IsDelegatedReader/IsDelegatedAdmin/IsDelegated, HasFleetScope
and AllowedTenantIds (the delegated subset, for Phase 2 aggregation).
Secret-leak hardening: GetTenantConfigurationFunction now redacts by default —
only a Global Admin or the tenant's OWN admin sees unredacted secrets
(CanViewSecrets, extracted as a testable static). Behavior is identical for all
pre-existing roles; it closes the path where a cross-tenant delegated/global
reader would otherwise receive SAS/webhook secrets.
12 new tests (delegated allow/deny matrix + CanViewSecrets clearance). Full
Functions suite green aside from two pre-existing timing-flaky tests (unrelated
code, pass in isolation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for "MSP mode" — a scoped-global authorization tier between a
single-tenant member and a platform GlobalAdmin. A delegated admin may access a
SUBSET of tenants (its assignment rows), resolved UPN-based and tid-agnostic,
exactly like GlobalAdmins.
Phase 0 is purely additive: the service is registered but no auth path consults
it yet (the cross-tenant gate change lands in Phase 1), so behavior is unchanged.
- Constants: DelegatedRoles/DelegatedStatus/DelegatedSource; new DelegatedAdmins
table added to TableNames.All (auto-create), CriticalBackupTables.All and
AuthTablesFullRestoreForbidden (identity table — full-table restore blocked).
- IAdminRepository + TableAdminRepository: DelegatedAdminEntry DTO + CRUD
(PK=UPN, RK=TenantId). Hot-path GetDelegatedTenantsAsync (partition scan) and
reverse GetDelegatedAssigneesAsync. Queries use the typed predicate overload
(no string-interpolated OData filter — avoids filter injection).
- DelegatedAdminService: resolves DelegatedScope (Covers/RoleFor/CanWrite) with a
5-min cache + write-through invalidation. Fail-closed: only Active+enabled rows
with a recognized role confer scope; empty role -> DelegatedReader; unknown -> dropped.
- 16 unit tests (fake repo); full Functions suite 2434/2434 green. Backup
catalog-count assertion bumped 15 -> 16.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ingest hot path + Graph auth latency. IngestTelemetry is the #1 backend
endpoint (~35.6k/day); its p99 tail was dominated not by parsing but by an
uncached Graph token: GraphTokenService minted a fresh client-credentials
token (500-1900ms POST) on every device-validation cache miss and threw it
away. Cache it per tenant.
GraphTokenService:
- Per-tenant token cache keyed on the validated tenantId; TTL from the
token's own expires_in minus a 5min skew margin, clamped to [60s, 55min]
(expires_in was previously ignored). Only successful tokens are cached.
- Per-tenant SemaphoreSlim stampede gate (bounded wait) so an enrollment
wave does not fan a burst of identical token POSTs at AAD.
- InvalidateTenant() drops the token + consent-status entries so the
consent/Graph-permission fresh-read contract still holds.
- Per-tenant invalidation generation closes the write-after-invalidate
race: a POST in flight when InvalidateTenant runs tags its entry with the
pre-invalidation generation, which readers reject.
GraphFeatureDetector:
- InvalidateTenant() now also invalidates the underlying token cache, and
applies the same generation guard to its own parsed-roles cache.
- Cross-layer order is load-bearing: invalidate the token layer FIRST, then
bump the detector generation, so a concurrent acquire cannot pair a new
detector generation with a stale token read.
IngestTelemetryFunction:
- Deserialize the request body directly from the buffered MemoryStream via
JsonTextReader instead of materializing a full UTF-16 string + ToArray()
copy (peak ~4N -> ~1N). Cap still applies before parsing; same parse
semantics. Pure allocation hygiene on the hottest endpoint.
Tests: token caching, cross-tenant isolation, failure-not-cached, stampede
coalescing (barrier-gated), in-flight write-after-invalidate, cross-layer
invalidation, and cross-layer ordering -- each verified red without its fix.
Parse hygiene: PayloadJson stays a raw string, malformed -> 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app dashboard and metrics endpoints loaded entire AppInstallSummaries
partitions (tenant scope) or the full table (global scope) and filtered
StartedAt >= cutoff in memory, even for days=30. The device-model join then
issued one serial GetSessionAsync point-read per distinct session.
- Add optional DateTime? sinceUtc to GetAppInstallSummariesByTenantAsync /
GetAllAppInstallSummariesAsync: when set, a server-side StartedAt ge filter
scopes the scan so only in-window rows are deserialized/transferred. Default
null preserves the legacy full scan for callers that need all history.
- AppsAnalyticsHelper.LoadSummariesAsync derives sinceUtc from days; all six
/api/apps/* functions pass it. The four metrics/app + metrics/geographic
functions pass their existing cutoff. The in-memory Where stays as the exact
sub-second trim (the OData filter is second-granular). MCP get_app_install_metrics
benefits automatically (days already flows through; response shape unchanged).
- Replace the serial session point-read loop with a bounded-concurrency
(SemaphoreSlim=10) LoadSessionLookupAsync, shared by the analytics and
sessions builders.
AppInstallSummaries is already bounded by the session-retention cascade
(DeletionManifestBuilder) + tenant offboarding, so no separate table retention
is added. Full Backend suite: 2406 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QuickSearchSessionsAsync walked the full SessionsIndex partition (or the
whole table for global scope) on every typeahead request, since
exact-over-fuzzy ordering forbids an early break on fuzzy-only/no-match
queries. Cost grew with the index.
Bound the scan without a new index:
- recent-window RowKey bound (RowKey lt cutoff, 90d) via the existing
ComputeCutoffRowKeyPrefix; applies to tenant and global scope
- hard scanned-row cap (5000) as backstop for the no-early-break case
Match/ordering logic unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The retention fanout materialized the entire eligible-session backlog every
run (GetSessionsOlderThanAsync returned a full List<SessionSummary>) but only
advanced MaxEnqueuesPerTenantPerRun=100 per tenant. A large backlog was reread
and rematerialized each run, so maintenance got slower as the backlog grew
while only 100 sessions progressed.
Cap the read at the per-run dispatch limit: GetSessionsOlderThanAsync gains a
maxResults parameter that caps maxPerPage and stops enumerating once the cap is
reached. The fanout fetches MaxEnqueuesPerTenantPerRun+1 (the +1 is a cheap
"more remaining" probe) instead of the whole backlog. Read cost is now
proportional to the cap, not the backlog; per-run progress is unchanged.
The exact eligible count is no longer known without a full scan, so the audit
now records EligibleThisRun + a MoreRemaining flag instead of the exact total.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both are self-hosted poll-loop workers (no QueueTrigger), so their poisoned
work was invisible to MaintenanceService poison-queue alerting and the
HealthCheckService health card. Add both poison siblings to
MonitoredPoisonQueues and update the watch-list guard test (4 -> 6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review #7 (MEDIUM): AuthenticationMiddleware read the unvalidated
`tid` claim and built a tenant-specific OIDC authority before signature
validation. A garbage tid would drive an outbound metadata fetch to a bogus
authority - a DoS/cost surface on malformed-token floods (not an auth bypass;
signature/audience/issuer are still validated afterward).
- Guard `tid` with Guid.TryParse before constructing the authority; missing/
non-GUID throws SecurityTokenValidationException -> 401, no fetch. Loss-free:
every legitimate Entra token (incl. consumer MSA 9188040d-...) has a GUID tid.
- Drop the now-unreachable null -> common/v2.0 fallback.
- Pass context.CancellationToken into GetConfigurationAsync (was None).
Negative cache for invalid/unreachable authorities deferred (LRU already
bounds memory; revisit only if floods observed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several append-only tables had no purge mechanism and grew unbounded. Add
age-based retention to the 2h MaintenanceFunction (and the manual path) via a
new CleanupUnboundedTablesAsync orchestrator (per-table try/catch, fixed const
windows, mirroring the DistressReports pattern):
- GlobalNotifications + TenantNotifications: hybrid retention — dismissed rows
pruned 30d after DismissedAt, any row pruned 180d after CreatedAt. Unread
(still-actionable) admin warnings survive the dismiss window so they are never
silently lost; the 180d catch-all bounds the table. Shared pure predicate
NotificationRetentionFilter.BuildPredicate (unit-tested).
- HardwareRejectionNotificationTracker: 30d (FirstNotifiedAt). Pruning re-arms
the lifetime dedup, so a model rejected again >30d later re-bells (intended).
- AuditLogs: 180d (system Timestamp, cross-partition).
- UsageMetrics: 180d (PartitionKey date-string range).
- BackupJobs: 365d (QueuedAtUtc).
PlatformStats intentionally excluded — it is a single upserted row, not
append-only. Bootstrap sessions left as-is (feature currently unused).
Each repo delete mirrors DeleteUserActivityOlderThanAsync: server-side
"X lt cutoff" filter + select PK/RK + delete loop. Deletes are resumable across
runs, so a partial run self-heals on the next cycle.
Observability: end-of-run MaintenanceLongRunning Warning OpsEvent if RunAllAsync
exceeds 10min, dual-registered in OpsAlertRulesSection.tsx, to surface a run
climbing toward the 60min functionTimeout before a future hard-abort (which
emits nothing).
Inert until backend redeploy; first post-deploy run bulk-prunes the backlog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Global-Admin cross-tenant view of web users active "now" (made an authenticated
request within a window, default 5min), backed by a new self-maintaining
UserPresence table. Not App Insights (sampling) / not SignalR.
Backend:
- UserPresence table (one row/user, PK=tenantId, RK=SHA-256(lowercase UPN));
upserted fire-and-forget by UserPresenceMiddleware (after PolicyEnforcement,
60s per-process throttle, UPN-gated so agent cert calls are skipped).
- GET /api/global/presence (GlobalReadOrAdmin) via GetActiveUsersFunction; read
path propagates storage errors (5xx) instead of masking them as "0 active".
- 1-day presence retention + 90-day UserActivity retention in maintenance;
PlatformStats.TotalUsers clamped monotonic so the public "Users Seen" figure
cannot regress after a prune.
Web:
- /admin/presence page (live counter + table), nav entry under GA > Metrics.
- "Total Users" relabeled "Unique Users (90d)" to match the windowed metric.
Backend+web only, no ConfigVersion bump, inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Born from /session-debug analysis of f148976f: a customer platform script
(c3e0124c) hung for the full ~30-min IME script-execution timeout, blocking
IME's serial platform-script queue, so the Autopilot-Monitor bootstrap +
agent only started ~34 min after boot (right as ESP hit its own 30-min
SyncFailureTimeout). The agent observed only the already-decided end-state
and lived ~30s — a session that looks like a normal multi-minute failure but
had near-zero coverage of the actual failure window.
Two detectors classify this automatically:
- agent_late_start (Warning, EnrollmentTerminationHandler): fires when the
agent started >=10 min after boot AND lived <=5 min before a terminal
outcome. Device boot via GetTickCount64 P/Invoke (net48 has no
Environment.TickCount64), monotonic / clock-jump-resistant. Shared gate in
Termination/ObservationCoverage.cs (DRY with the builder). final-status.json
gains bootToAgentStartSeconds + lowObservationCoverage (FinalStatusBuilder
stays pure via an optional deviceBootUtc param).
- script_timeout_suspected (Warning, ImeLogTrackerAdapter): a platform script
that ran >=25 min AND ended Failed -> one-shot per policyId, naming it as the
likely enrollment-pipeline blocker. Works on replay (StartedAtUtc from the
CMTrace source timestamp), so it fires even when the script ran before the
agent launched.
Plus per-script run duration for all platform scripts: new
ScriptExecutionState.StartedAtUtc -> durationSeconds on every
script_completed/script_failed -> Web ScriptExecutions renders "Duration"
(amber past 10 min) so slow/inefficient scripts surface generally.
Scope: platform scripts only (remediation/health-script duration deferred --
the multi-source HS reducer makes clean attribution fragile). No ConfigVersion
bump, no backend code (ingest is event-agnostic). New event types wired through
the mandatory dual-sync: Constants.EventTypes -> MCP resource-catalog ->
Web eventTypes (drift tests enforce).
Tests: V2.Core 1536 (+88), MCP drift 6, MCP/Web tsc 0, Web 84. The f148976f
case is reproduced in unit tests (ObservationCoverage 34min/30s; adapter
c3e0124c 30min/Failed). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a user authenticated successfully but was not on the MCP whitelist,
the MCP server returned an opaque "MCP access denied" 403 — the user could
not tell why auth was interrupted or that they simply needed to be
whitelisted by the server owner.
- access-guard: distinguish a genuine authorization denial (not whitelisted)
from an infrastructure failure (backend unreachable / malformed) via a new
infraError flag on AccessCheckResult. Only a genuine deny gets the
user-facing "User not enabled for MCP usage" error plus an actionable
message naming the account and pointing at the fix (ask to be whitelisted).
Infra failures keep a neutral "MCP access check unavailable" message so the
user is not sent chasing the wrong fix. The reason field still echoes the
backend reason verbatim.
- backend McpUserService: WhitelistOnly denial reason is now self-explanatory
("User not enabled for MCP usage (account is not on the MCP whitelist)").
- tests: cover the actionable whitelist message and that an infra failure is
not mislabelled as a whitelist problem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKYavm4FAu66qnhX5nvmMT
The ticket-gated diagnostics/download route (PublicAnonymous, MCP session_debug)
401'd on the deployed backend: AuthenticationMiddleware kept its OWN hardcoded
_anonymousRoutes allowlist, parallel to EndpointAccessPolicyCatalog, and the new
route was added only to the catalog. Since AuthenticationMiddleware runs before
PolicyEnforcementMiddleware, the missing entry rejected the request with a JWT
401 before the PublicAnonymous policy was ever honored. Classic hand-synced
parallel-catalog drift.
Delete _anonymousRoutes / _anonymousPrefixes and derive the JWT-exempt decision
from the catalog: a route skips JWT iff its policy is PublicAnonymous or
DeviceOrBootstrapAuth (authenticated in-function via cert/bootstrap token, not
JWT). Now method-aware and fail-closed on unregistered routes. Behavior-
preserving: the old 14 routes + 1 prefix equal exactly the catalog's
PublicAnonymous u DeviceOrBootstrapAuth set.
Add drift guard in EndpointPolicyCatalogCompletenessTests:
AnonymousAndDeviceRoutes_AreJwtExemptInAuthMiddleware loops every such catalog
entry and asserts the middleware exempts it (would have caught this bug);
SkipsJwtValidation_MatchesPolicyTier locks the positive/negative tiers incl. the
diagnostics/download vs diagnostics/download-url contrast. Full suite 2393 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets tenant-admins and GAs run end-to-end session debugging over MCP: backend
telemetry plus the agent diagnostics ZIP, downloaded and analyzed locally by the
AI client. The backend never unzips/parses the archive (no parse CPU); it only
mints a short-lived signed download ticket and proxy-streams bytes.
Backend (no ConfigVersion bump, no agent change):
- DiagnosticsDownloadTicket: HMAC codec (reuses PaginationTokenSigningKey,
purpose-tagged diag-dl-v1, 10-min TTL) so a no-JWT client can download.
- DiagnosticsBlobStreamer: shared proxy-stream engine; DiagnosticsDownloadFunction
(web/JWT) refactored onto it. ClassifyBlobName/BlobDestination stay put (xUnit).
- POST diagnostics/download-ticket (MemberRead, QueryParam, GA-bypass) +
GET diagnostics/download?t= (anonymous; tenantId/blobName read only from the
signed ticket -> no retargeting). Both added to NoStoreCacheMiddleware.
MCP:
- get_session_diagnostics tool (all roles) -> absolute downloadUrl + static zipMap
+ instructions; unwraps the {success,session} envelope.
- diag_zip_layout catalog (get_resource + protocol resource).
- debug-session prompt (tenant + GA variants).
- Tool descriptions flag the client requirement (needs local file/shell tools;
pure chat clients cannot unzip).
Tests: backend 2380/2380 (ticket codec, NoStore, classify, catalog completeness);
MCP 261 passed (incl. envelope-unwrap regression). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two V2 IME fixes, verified against client logs of sessions fc48c71a / 6b4993e5.
Bug 1 — on an ESP terminal failure of the DeviceSetup Apps subcategory the only
device app was stuck mid-install (RealmJoin stuck Downloading 0%) and IME logged
no per-app error, so app_tracking_summary showed 0 failed and the failure was
visible only via the opaque "Apps (Error)" registry string. New
esp_apps_failure_correlation event names the in-flight (IsActive: InProgress/
Downloading/Installing) device app(s) as the likely cause WITHOUT mutating state
(no fabricated app_install_failed) and only on the Apps subcategory.
Bug 2 — AgentExecutor.exe hosts both platform and proactive-remediation scripts
interleaved in the shared AgentExecutor.log; the policyId-less PS-AGENT-EXITCODE /
PS-AGENT-OUTPUT lines from a remediation invocation bled into the last-started
platform script via the sticky pointer (c3e0124c emitted result=Failed but with
exit 0 + the Teams remediation's "[Compliant] No Classic Teams found" stdout).
New PS-AGENT-INVOCATION pattern (resetPlatformScriptContext) clears the platform
line-capture pointer at every "ExecutorLog AgentExecutor gets invoked" boundary,
so a remediation invocation cannot capture into a platform slot; the platform
result stays keyed by policyId via PS-SCRIPT-RESULT.
Agent-only, V2, inert until redeploy; no ConfigVersion bump. New event type added
to the MCP resource catalog (dual-sync). Green: V2.Core 1522, DecisionCore 400,
Backend BuiltInRules 8, MCP vitest 252.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.
Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
(incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
(writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole
MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.
Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.
Tests: backend 2369, MCP 252, web 469 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GA "Edit Tenant Configuration" dialog only exposed Disable/Remove for
tenant members, while the tenant-admin Access Management view already had a
role dropdown. Backend already permits GA on the permissions endpoint
(TenantAdminOrGA), so this brings the GA component to parity:
- per-member Admin/Operator role dropdown (handleUpdatePermissions)
- "Can manage bootstrap tokens" toggle for Operators
- role selector on Add New Admin (POST now sends role)
Web-only, no ConfigVersion bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V2 emitted script_completed for platform scripts ONLY on the IME PS-SCRIPT-RESULT
line (IntuneManagementExtension.log). The AgentExecutor.log exit code + stdout merely
filled _pendingPlatformScripts and never emitted on their own. IME logs its policy
result after its batch-send to the service, so scripts that finish shortly before the
(often short) Autopilot enrollment ends were silently dropped — the buffer is neither
persisted nor flushed on dispose. Field-confirmed on session d1da052d: 4 platform
scripts ran, only the long-running one (whose IME result was logged in time) reached
the backend.
Add a deadline-based fallback in the live polling loop: a pending platform script with
a known exit code but no PS-SCRIPT-RESULT within a 15s grace is emitted from the exit
code (0 -> Success, else Failed), tagged resultSource=agentexecutor_fallback so the
provenance is honest. A force pass runs on shutdown. Dedup via _platformScriptResultEmitted
prevents a double emit when the authoritative result arrives after the fallback; a fresh
scriptStarted clears the marker so IME re-evaluations/retries of the same policy emit again.
The fallback event binds to the script's own exit timestamp (not the last-matched line)
and omits the stale patternId.
Reuses script_completed/script_failed (no new event type, no ConfigVersion bump, no MCP
catalog change). V2-only; legacy V1 agent unchanged. 1516/1516 V2.Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate copy-blueprint rules (templateVariables, e.g. PPKG allow-list
SEC-005/006) from real rules into their own tab so users see they are
templates, not directly-active rules. Customer feedback.
- New pure helper lib/analyzeRuleTabs.ts (isTemplateRule, partitionAnalyzeRules) + test
- page.tsx: activeTab state; tab bar shown only when >=1 template exists
(unchanged for tenants without templates); Type filter hidden/ignored in
Templates tab; create-new only in Rules tab; after create-from-template,
switch to Rules tab + auto-expand the new custom copy
- AnalyzeRuleCard: new variant="template" prop -> static glyph instead of
toggle + banner: green "already created: {id}" with View/edit jump, or
amber "Create custom rule". Default-list behaviour untouched.
Web-only, no Backend/Shared/ConfigVersion change. tsc clean, 469/469 vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds WebhookProviderType.GenericJson (20) so a tenant can POST a stable,
channel-agnostic JSON payload to any HTTPS endpoint - ticketing systems,
automation, or an SMTP gateway like Postal - instead of only Teams/Slack.
Backend:
- GenericJsonRenderer emits schemaVersion 1.0 with eventType, severity,
title/summary/themeColor, primaryUrl (first openUrl action), facts[],
sections[], actions[]; registered in WebhookNotificationService.
- NotificationAlert.EventType set on all 8 builders (enrollment_succeeded/
_failed/_started, preprovisioning_*, hardware_rejected, sla_breach/_resolved,
consecutive_failures, test) for consumer-side routing.
- Per-tenant WebhookCustomHeadersJson + GetGenericWebhookHeaders() (generic-only,
restricted-header denylist, fail-soft parse) for API-key auth; dispatcher
gained optional customHeaders applied via HttpRequestMessage; threaded into
all 12 tenant call sites (Ops/admin-config sites unchanged).
- TableConfigRepository persists the field; UpdateTenantConfigurationFunction
validates it (object, string values, RFC-7230 token names, CRLF-injection
guard, 8 KB / 25-header caps).
Web:
- Generic provider option + key->value WebhookHeaderEditor (rendered only for
provider 20); leaving the generic provider clears local headers so a later
switch back cannot revive stale auth headers; context/types wiring sends
headers only when provider===20; report shows Generic JSON provider name.
ConfigVersion unchanged (backend-only, no agent payload). No new
Constants.EventTypes, so no MCP catalog sync. Still fire-and-forget (no SMTP
client / retry by design). New GenericWebhookTests cover renderer, header
parsing/denylist, and save-time validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Surface WMI watcher arm failures: new ConsoleBypassWatcher.WatcherArmFailed
event -> host emits collector_degraded (reason=watcher_arm_failed) via
CollectorDegradationReporter, so the backend can tell "no console seen" from
"live detector never started" instead of both looking like a clean run.
- Close a startup blind gap: arm the live Win32_ProcessStartTrace FIRST, then
run the startup snapshot. A cmd spawned between snapshot and arm was previously
missed; the _seen set dedups the now-possible overlap.
- Fix command-line classification for combined cmd switches: strip the executable
token, then match /c|/k anywhere in the arguments (was anchored to whitespace),
so e.g. `cmd /d/q/c exit 9` is correctly scripted (was false high-confidence).
- Docs: replace stale parent-based wording (AgentConfigResponse, the Web analyzer
toggle help text, the prefetch coverageNote) with the interactive-session model.
Tests: combined-switch + StripExecutable cases added. V2.Core 1505/1505.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field session f240a555 (Win11 24H2) disproved the parent==winlogon assumption:
the Shift+F10 cmd's parent (pid 9016) was a short-lived non-winlogon launcher
that exited within ms ("parent resolve failed ... is not running"), and the cmd
ran as defaultuser0, not SYSTEM. The conservative "can't confirm winlogon -> don't
flag" path therefore dropped a real Shift+F10 console.
ConsoleBypassWatcher rewritten to be parent-free:
- Gate race-free on SessionID != 0 (interactive session) from the trace — IME/
Intune install `cmd /c` run as SYSTEM in session 0 and are dropped without a probe.
- Confirm on the cmd's OWN command line: bare (no /c|/k) = interactive shell ->
confidence=high; a scripted `cmd /c` is ignored.
- Instant-close race (command line unreadable before the probe) -> still surfaced
as confidence=low (interactive_session_unclassified) rather than missed.
- Event data now: sessionId, owner, commandLine, confidence, classification,
parentProcessId (dropped parentProcessName/parentMatchesWinlogon).
Lifecycle gate: ConsoleBypassHost.StopForDesktopArrival() wired into the
DesktopArrival onRealUserOwnerObserved callback — once the real-user desktop
arrives (excludes SYSTEM + defaultuser0) Shift+F10 is no longer possible, so the
watcher stops and cannot flag ordinary post-enrollment user consoles. AccountSetup
phase alone is too early (the portal shows it before the real desktop, while
Shift+F10 still works), so desktop arrival is the binding stop signal.
Tests rewritten: Classify + HasScriptArgument (pure), high/low-confidence,
session-0 ignore, scripted ignore, dedup, owner enrichment. V2.Core 1499/1499.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Detects a SYSTEM console opened during enrollment — the classic Shift+F10
OOBE bypass (winlogon.exe spawning cmd.exe as SYSTEM). Two complementary
Warning signals, one per-tenant toggle EnableConsoleBypassDetection
(default ON / opt-out), ConfigVersion 31 -> 32:
- oobe_console_spawned (live): ConsoleBypassWatcher + ConsoleBypassHost.
WMI Win32_ProcessStartTrace on cmd.exe + startup probe; flags only a
cmd.exe whose parent is winlogon.exe. PID dedup happens ONLY after a
confirmed winlogon match, so an ignored cmd cannot suppress a later real
one via PID reuse.
- console_prefetch_detected (startup forensic): ConsolePrefetchScanner
(IAgentAnalyzer). A CMD.EXE-*.pf prefetch artifact whose last-run is
after boot, covering the pre-agent OOBE window the live watcher misses.
stat-only; corroborating artifacts sorted for a stable gate fingerprint.
Both events carry coverageComplete:false + an honest coverageNote (and the
prefetch event attribution:"coarse") — detection is best-effort, not gapless,
and only reports, never blocks.
Plumbing: Constants (2 event types) + MCP resource-catalog.ts + Web
eventTypes.ts; AnalyzerConfiguration/TenantConfiguration + GetAgentConfig
(default true) + TableConfigRepository; DefaultComponentFactory host gate +
AgentAnalyzerManager registration; full Web opt-out toggle. Tests: watcher
classification (9) + prefetch scanner (7) + analyzer-manager on/off.
Also fix(web): unrestricted-mode toggle persisted the stale pre-toggle value
because onSave() read async React state. saveConfiguration now takes an
explicit unrestrictedMode override that the toggle passes through, so the
saved value matches the UI state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Webhook notifications built /session/{tenantId}/{sessionId}, which is a
dead route (404). The web app route is /sessions/{sessionId} (plural,
single segment); tenant is resolved from the session, not the path.
Fixes broken "Open session" links in enrollment-started and
success/failure notifications.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real misalignment was the icon, not the label. Nav icons are
created without a className (e.g. <HomeIcon/>), so each SVG keeps its
component default w-5 h-5 (20px) while the wrapper span only sized the
span to w-4 h-4 (16px). A 20px inline, baseline-aligned SVG inside a
16px box does not sit at the box's vertical center, so it never lined
up with the adjacent text — and no label nudge could fix that.
Make each icon wrapper an inline-flex with items-center so the SVG is
centered as a (blockified) flex item, independent of the size mismatch
and free of inline-baseline offset. Drop the earlier 1px label nudges,
which compensated the wrong element.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0.5px under-corrected the optical high-sitting of full-mode nav labels;
bump to 1px so text centers on its icon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Nudge full-mode nav labels down 0.5px so text optically centers on
its icon (font ascender slack made labels sit slightly high).
- Wrap bare <li> usages (Dashboard, regular-user items, public Home)
in <ul> — a li without a ul parent kept display:list-item and
rendered a default disc ::marker (invalid HTML).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In larger tenants the multi-tenant app is often pre-approved by someone
with consent rights, while the admin operating our UI lacks them. The
/adminconsent redirect then fails, so ValidateAutopilotDevice/
ValidateCorporateIdentifier never persist and the tenant stays "Disabled"
even though the app + DeviceManagementServiceConfig.Read.All effectively
exist. That bool is also the hard gate in SecurityValidator, so a UI-only
badge would leave agent traffic 403'd.
Add a read-only access-check probe that verifies the concrete role in the
SP token's roles claim (via GraphFeatureDetector, not mere token
acquirability) and, when present, persists the gate bool through the
normal config PUT — opening the UI badge AND the agent gate together.
Backend:
- GraphAppPermissions.DeviceManagementServiceConfigReadAll constant.
- GET /api/config/{tenantId}/autopilot-device-validation/access-check
(TenantAdminOrGA): InvalidateTenant + fresh GetSnapshotAsync (4s budget),
pure ResolveAccessPresent (transient => never present), emits
ConsentAccessDetected telemetry. Always-fresh so a propagation-time
empty-roles snapshot can't wedge the admin out for the token TTL.
Web:
- lib/accessCheck.ts classifyAccessCheck pure fn (transient/absent never
reconcile) + tests.
- Reconcile on consent-redirect failure (silently enables) and a per-toggle
"Detect existing access" button for Autopilot, Corporate, and DevPrep
Device Association (all share the same role).
- Successful consent callback now verifies the concrete role too: persists
ONLY on authoritative "reconciled"; transient => retry/propagation
message, absent => "permission not granted" — neither opens the gate.
- saveConfiguration returns boolean so success is shown only after a
confirmed persist.
No ConfigVersion bump (backend + web only). Backend 2326/2326, web 464/464.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DiagnosticsUploadEnabled was gated solely on the per-tenant
DiagnosticsBlobSasUrl. Hosted-destination tenants have no such URL
(the platform owns the storage), so the agent received
DiagnosticsUploadEnabled=false and silently skipped every upload —
even with DiagnosticsUploadMode=Always. No diagnostics_collecting /
_uploaded / _upload_failed events were ever emitted.
Extract the gate into ResolveDiagnosticsUploadEnabled and enable when
a customer SAS is set OR the destination is Hosted. Config-only change,
no ConfigVersion bump; applies on next agent config poll after deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>