Workaround for clients (e.g. Claude Code's HTTP-MCP bridge in stateless
mode) that don't expose `resources/list` reliably. Verified with a paired
in-memory client that the server itself advertises `capabilities.resources`
and answers `resources/list` correctly — the breakage is on the client
side. Tools work everywhere, so a tool-call gateway is the pragmatic fix.
Resources now share a single source-of-truth (`resource-catalog.ts`) used
by both the MCP-protocol resource handlers and the new `get_resource(name)`
tool. Tool descriptions previously said "see the device_properties resource"
— updated to "call get_resource(name=...)" so the hint actually leads
somewhere usable.
Also tightens get_rule_stats description: the default window × number of
rules × per-day trend can easily exceed the response cap, so the doc now
recommends passing a tighter date range or ruleType filter for routine
queries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the silent confusion where a Global Admin who passed `tenantId=X`
got routed to the tenant-scoped endpoint, which used the GA's own JWT
tenant (home), not the requested one. The user saw their home tenant's
data instead of X — no error, just wrong data.
Caller role flows from /api/auth/mcp (now returns isGlobalAdmin) into
an AsyncLocalStorage caller context (token + isGlobalAdmin). Tools pick
the route via a small helper:
pickGlobalOrTenantPath('/api/global/X', '/api/X')
GA → /api/global/X (cross-tenant; tenantId is a filter param)
Tenant-Admin → /api/X (tenant-scoped; JWT-tid authoritative, isolation strict)
Refactors 13 routing sites across tools/{sessions,admin,search}.ts. Each
backend pair was verified to exist (both `/api/X` and `/api/global/X`
have a Function defined).
Also drops the MCP-side filter pass in get_session_events — server-side
filtering was added in the previous commit so count and nextLink are
now coherent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The full LocationSessionRow shape is ~40 fields per session — for
locations with dozens of sessions the response easily blew past 100KB
and tripped MCP token caps. Most fields aren't needed for the typical
"which sessions enrolled from here" triage flow.
Default is now a 17-field lean projection (sessionId, device basics,
status/duration, geo, app/DO summary). UI keeps the full shape via
?full=1 — single-line addition in lib/api.ts.
Also adds an optional tenantId filter on /api/global/metrics/geographic/
sessions so a GA can scope a single tenant's sessions for a location
without leaving the global endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push eventType/severity/source filters from MCP-tool layer down into the
Function so count and nextLink stay coherent. Previously filters lived
client-side: backend returned the full page, MCP filtered → could
produce count: 0 with nextLink set, which read as a bug ("nextLink is
emitted even though there's nothing left").
With server-side filters:
- count reflects post-filter items in this page
- nextLink is still gated on NextRawToken (more raw rows ahead in this
session — filter may or may not match further down)
- filter values echo back onto nextLink so callers can follow pagination
without having to re-supply them
SessionEventsPagination.BuildNextLink learns an optional `extras` arg so
arbitrary query params survive the round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both endpoints already supported optional date-window + pagination but
ignored a tenantId query param. With the upcoming MCP role-aware routing
GA needs /api/global/* endpoints to filter to a single tenant while
keeping cross-tenant capability when the param is absent.
- audit/logs: routes to existing tenant-scoped IMaintenanceRepository
methods when tenantId is set (cheap PK lookup); fingerprint scope
widened to "audit:global:tenant:<id>" so cross-token reuse fails.
- ops-events: OpsEvents are partitioned by category, not tenant — the
filter is applied post-fetch. Pages may report fewer items than
pageSize when the filter is narrow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCP server needs to know the caller's GA status to make routing
decisions (GA → /api/global/* with tenantId filter; non-GA → /api/*
JWT-bound). Previously the access-check response only carried
allowed/reason/accessGrant — the MCP layer had no way to detect role.
McpUserService now resolves IsGlobalAdmin unconditionally (it's needed
for both whitelist and AllMembers paths), and the result rides through
on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays
backward-compatible via default-param.
Prep for the upcoming MCP role-aware routing refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/
.json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from
/settings/tenant/support without needing a session context — no more
OneDrive round-trips for ad-hoc client-log requests.
Backend
- New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table
+ session-reports container, distinguished by ReportType="diagFiles".
- ReportType discriminator added to SessionReportMetadata + Store/Map
(legacy rows default to "session").
- Telegram + Global-Admin bell notifications mirror the session-report flow.
Frontend
- New "Submit Logs" tenant settings tab with comment / email / log+state
files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate.
- Admin SessionReportsSection now shows a Type column (Session / Diag Files)
and hides Session ID in the detail modal for diag-files rows.
Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc
--noEmit clean, 259 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a drop-in wrapper around authenticatedFetch that collapses concurrent
GET/HEAD requests to the same URL into a single underlying network round-
trip. Each caller receives an independently readable Response clone.
This is a request-collapser, NOT a cache. Entries live only as long as
the underlying Promise is unresolved; once it settles, the entry is
removed on the next microtask. No TTL, no eviction, no staleness — by
design, since SignalR push-truth would race against any pull-cache. Calls
that merely happen to hit the same URL after a response has arrived go
fresh.
Mutations (POST/PUT/PATCH/DELETE) bypass the collapser unconditionally
so write semantics are unchanged.
Migrated two known-duplicating callsites:
- useTenantSecurityConfig (dashboard) — feature-flags GET that re-fired
on the user=null → UserInfo transition.
- useSessionTenantConfig (session detail) — feature-flags GET that
could overlap with other consumers on the same render burst.
Belt-and-suspenders: the prior dep-stabilization + !user guard on
useTenantSecurityConfig stays in place; the collapser is defense-in-
depth so future regressions of the same shape are caught generically.
8 new vitest cases cover: concurrent collapse, independent body reads,
no caching across microtask boundaries, different URLs not collapsed,
mutations bypass, rejection propagation + slot release, HEAD collapsing,
and GET vs HEAD keyed separately. 257/257 web tests, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-deploy re-validation in the browser surfaced two issues that the
previous commit did not actually solve:
- Timing-Allow-Origin was still missing on every API response (curl
confirmed) and X-Correlation-ID was missing too — a preexisting bug.
Both middlewares used httpContext.Response.OnStarting() to attach the
header, but that callback is not reliably triggered in the .NET 8
isolated worker: the host bridges the worker's response to the wire,
so OnStarting fires on a shadow object that never reaches the client.
UserRateLimitMiddleware already writes X-RateLimit-* directly on
Response.Headers before calling next(), and those headers do come
through in production. Switching both header-emitting middlewares
to that pattern.
- /dashboard kept firing /api/config/<t>/feature-flags twice. The
earlier dep-stabilization on useTenantSecurityConfig was incomplete:
the effect fires once on initial render with user=null (deps
[tenantId, undefined, undefined, undefined]) and once after AuthContext
resolves user (deps [tenantId, bool, bool, role]) — two distinct dep
tuples, two fetches. The user=null path also bypassed the role gate
(the guard was `if (user && ...) return`). Adding `!user` to the
early-return at the top of the effect collapses to one fetch and
also drops the would-be 401 we were producing during the loading
window.
Backend tests 1058/1058, web vitest 249/249, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by
sequential backend reads, two stray duplicate fetches, and a 2s SignalR
defer. Five focused changes:
- AuthContext: store the auth/me prefetch as a Promise instead of its
resolved value so fetchUserInfo awaits the in-flight request rather
than racing a second fetch when the prefetch hasn't settled yet.
- useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/
role primitives instead of the whole user object so the effect stops
refiring when AuthContext swaps prefetched user for fresh user (the
duplicate /config/<t>/feature-flags call observed on /dashboard).
- SignalRContext: drop the hardcoded 2000ms setTimeout before
startConnection. The original "avoid competing with critical API
calls" rationale doesn't apply across origins (web is HTTP/2, API is
a different host); the delay only pushed live updates ~2s into the
load for no benefit.
- FeedbackFunction: keep the kill-switch check sequential, then run the
remaining four Table reads (role, tenant config, sessions existence,
feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped
from ~1285ms (6 sequential reads on Flex Consumption) toward the
slowest single read.
- TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every
function response so the portal's Resource Timing API can read API
payload sizes (transferSize / nextHopProtocol) for our own RUM —
cross-origin entries currently report 0/unknown.
Backend tests 1058/1058, Web tsc clean, vitest 249/249.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The paged scan path (`SearchSessionsByScanPageAsync`) routed AgentVersion +
AgentVersionPrefix through `MatchesScanClientFilters` — applied AFTER the
SessionsIndex page was already fetched. For sparse subsets like "all V2
enrollments" (~1% of sessions) this meant the LLM had to follow 10-30 nextLink
roundtrips of mostly count:0 pages while the index walked past the dense
non-V2 majority.
Move both filters into `BuildSearchScanFilter` as native OData clauses, same
trick `DeviceName` and `OsBuild` already use:
AgentVersion eq 'X' (exact)
AgentVersion ge 'X' and AgentVersion lt 'X~' (prefix)
Drop the now-redundant client-side check in `MatchesScanClientFilters`. The
device-snapshot path (`SearchSessionsByDeviceSnapshotPageAsync`) keeps the
client-side filter in `ApplyBasicFilters` because DeviceSnapshot doesn't carry
AgentVersion as a column.
Result: search_sessions(agentVersionPrefix="2.0.") becomes a single-call
operation for typical tenants instead of a roundtrip marathon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real-world test exposed two distinct LLM-context bottlenecks the pagination
rollout alone didn't solve:
1. Anthropic's MCP client truncates tool responses at ~25-30k chars by
default, mid-page; nextLink follows but rows in the truncated middle are
silently lost. pageSize alone can't fix this — even pageSize=50 of full
SessionSummary objects (~1.5KB each) blows the cap.
2. Aggregation queries ("how many V2 enrollments?") shouldn't need 1.5MB of
SessionSummary blobs paginated across 4 round-trips. The LLM only needs a
handful of fields to count.
Three coordinated fixes, all server-driven so customers connecting via MCP
get them automatically without any client config:
* `_meta.anthropic/maxResultSizeChars` annotation on every tool response,
calibrated per response density (sessions=300k, events=250k, rawTable=500k,
small=50k). The Anthropic client respects it and stops mid-page truncation.
New `toolResultText()` helper in shared.ts wraps the pattern.
* `fields=` projection on `search_sessions` and `query_raw_sessions` (server-
side, prunes wire payload 5-20x) and on `query_table` (client-side, since
TableEntity columns are dynamic). Always retains PartitionKey + RowKey for
cursor stability. Tool descriptions now actively recommend it for counting /
aggregation queries.
* `agentVersionPrefix=` / `imeAgentVersionPrefix=` filters on `search_sessions`
and `query_raw_sessions`. Replaces the "one call per build" loop pattern when
the LLM wants a whole version line. Mutually exclusive with the exact-match
variants (exact wins). Fingerprint-bound so a token from "2.0." can't replay
into "2.1.".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clears 3 moderate Dependabot alerts (GHSA-9vqf-7f2p-gf9v, GHSA-69xw-7hcm-h432,
GHSA-v2v4-37r5-5v8g) — all transitive via @modelcontextprotocol/sdk and
semver-compatible patches:
hono 4.12.15 -> 4.12.18
ip-address 10.1.0 -> 10.2.0
express-rate-limit 8.3.1 -> 8.5.1
The vulnerabilities are not on hot paths in our code (MCP uses express not
hono; ip-address only for rate-limit key derivation, never HTML emission), but
keeping the lockfile clean removes a recurring noise source on PRs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
query_raw_sessions, search_sessions_by_cve, and query_table were still on the
legacy `?limit=` contract (capped at 200/500/100 with no nextLink). On large
deployments this made "all devices affected by CVE-X" or "every row in
TenantConfiguration" structurally un-answerable.
Migrate to the established pageSize/continuation/nextLink contract:
- query_raw_sessions reuses SearchSessionsPagination; field projection logic
unchanged.
- search_sessions_by_cve gets a new SearchSessionsByCvePageAsync repo method
(analog to by-event) plus SearchSessionsByCvePagination helper with
cveId+CVSS/risk fingerprint binding.
- query_table gets a generic RawTablePagination helper covering tableName +
PK/RK/filter scope.
Sweep all pageable MCP tools to use followNextLink so backend-echoed query
params round-trip correctly. Raise session-list defaults 50 to 200 and unify
tool descriptions on a "pagination-first" message: pageSize is the batch size,
not a truncation point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Driving loadMore() from the auto-load effect in page.tsx left a one-render
gap where loadingMore was still false, so the snap-back effect in
useDashboardFilters fired first and reverted currentPage from 2 back to
totalPages=1 before the fetch even started. The fetch then ran (the
auto-load effect saw the captured currentPage=2 from that render) and the
list grew to totalPages=2, but currentPage had already been snapped to 1
— giving the visible "Page 1 of 2+" state and forcing the user to click
Next a second time to actually see the new batch.
Now handleNextPage calls loadMore() in the same click handler as
setCurrentPage(prev+1), so React 18's automatic batching commits both
state changes in the same render. Snap-back's `if (loadingMore) return`
guard then correctly skips, currentPage stays at the overshoot, and once
the fetch resolves totalPages catches up and the new batch renders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dashboard: removed the standalone "Load 100 more sessions..." button. The
session table now has a single Prev/Next surface that transparently fetches
the next server batch when the user pages past locally-loaded sessions —
the existing auto-load effect in page.tsx already drives loadMore() once
currentPage * sessionsPerPage exceeds sessions.length, but handleNextPage
was clamping to totalPages so currentPage could never overshoot, leaving
"Next" disabled until the user clicked the separate Load-More button.
- useDashboardFilters now takes hasMore + loadingMore; handleNextPage
advances past totalPages when hasMore && !loadingMore. A snap-back
effect resets currentPage to totalPages once a load finishes that
didn't grow totalPages (filter case: new batch yielded zero matches).
- SessionTable renders the Prev/Next controls whenever there's somewhere
to navigate (totalPages > 1 || hasMore), shows "Page X of Y+" while
more is on the server, disables Next only at the true end, and shows
an inline spinner inside Next during loadingMore. The empty tbody
distinguishes "loading sessions..." from "no results".
Fleet Health: bumped FLEET_PAGE_SIZE from 200 to 1000 (backend MaxPageSize
is 1000). A 90-day window on a busy install now drains in ~1/5 the
round-trips, eliminating the 5x roundtrip-amplification users observed
on initial load.
Type-check clean, vitest 249 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the planned 6-PR pagination rollout (project_mcp_pagination_rollout)
as one tested batch covering session_events, audit+ops, session_reports,
search_sessions/list, and query_raw_events. Backend builds clean; Backend
1024 / Agent V2 1007 / Web 249 tests green.
Primitives (Shared/Pagination):
- ContinuationToken with fingerprint binding (tenantId, dateFrom/To, filters)
so a forged or cross-tenant token fails validation on the next page.
- PaginatedResult<T> + RawPage<TEntity> as the canonical wire shapes.
Backend (AutopilotMonitor.Functions/Pagination):
- AzureTablesPaginator drives single-partition + per-partition pages.
- DateWindowPagination sweeps day-buckets for global audit/ops queries
(the backend resolves "last 30d" once and echoes dateFrom/dateTo on
nextLink so follow-up calls don't recompute "now" and break the token).
- PerPartitionFanOutMerge stable-merges N partition cursors for cross-
tenant session search.
- Per-feature plans (SessionEventsPagination, SearchSessionsPagination,
SearchSessionsByEventPagination, SessionListPagination,
SessionReportsPagination, QueryRawEventsPagination).
- Functions adopt nextLink: GetSessionEvents, GetAuditLogs (tenant +
global), GetOpsEvents, GetSessionReports, SearchSessions,
SearchSessionsByEvent, GetSessions, GetAllSessions, QueryRawEvents.
- Repository contracts (ISessionRepository, IOpsEventRepository,
INotificationRepository, IMaintenanceRepository) gain *PageAsync
variants returning PaginatedResult; legacy GetSessionsAsync becomes
a drain-all helper used by maintenance/feedback/SLA paths that still
need bounded full-list semantics. SessionPage DTO removed.
- PlatformMetricsService now drains all date-bounded sessions via
GetAllSessionsAsync (previous maxResults:500 was a silent truncation).
Web:
- lib/paginationLink.ts + ?? api.ts wrappers expose follow-nextLink in
hooks (useSessionEvents, useDashboardSessions) and pages (audit,
fleet-health, session detail, OpsEvents, SessionReports admin).
- Session list uses Pattern B2 (load-more append); audit + ops + reports
use Pattern B1 (click-next replace); session detail timeline keeps
Pattern A (eager-fetch full set).
MCP:
- followNextLink helper accepts either an opaque continuation OR the
whole "/api/?" path the backend emitted, and follows the latter
verbatim so server-resolved query params (tenantId fingerprint,
resolved dateFrom/dateTo, page size) round-trip correctly. Without
this, get_audit_logs / get_ops_events / list_session_reports / sessions
follow-ups would silently fail token validation.
- get_audit_logs, get_ops_events, list_session_reports, and the session
tools migrate to pageSize + continuation/nextLink, with tool docs
spelling out the round-trip rule so external MCP clients pass the
full nextLink instead of stripping it down to "continuation".
Tests:
- New: ContinuationTokenTests, DateWindowPaginationTests,
PerPartitionFanOutMergeTests, AuditLogRowKeyOrderingTests,
SessionEventsPaginationTests, SessionListPaginationTests,
SearchSessionsPaginationTests, SearchSessionsByEventPaginationTests,
SessionReportsPaginationTests, QueryRawEventsPaginationTests, and
TestSetup helpers.
- Web: paginationLink.test.ts + sessionListLoadMore.test.ts.
- PlatformMetricsServiceWindowTests adjusted for the drain-all change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous matcher ran middleware on /.well-known/microsoft-identity-association.json,
BingSiteAuth.xml and other public/ assets at the root, which on www would
have 302-redirected them to portal — breaking publisher-domain
verification fetches and host-bound webmaster files.
Excluding any path containing a dot is the standard Next.js pattern: app
routes and route handlers have no extension, so they still flow through
middleware; static assets and well-known files are served directly by
the host they're requested on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds host-based routing middleware so www.autopilotmonitor.com keeps the
public/marketing surface (SEO-indexed) and portal.autopilotmonitor.com
hosts the authenticated app. Apex is handled by Strato HTTP forwarder
(301 to www, path-preserved); the apex branch in middleware is
defense-in-depth for direct DNS hits.
Sign-In on the public host now hands off cross-origin to portal so MSAL
fires on the right origin and the token lands in portal's sessionStorage
directly. This avoids a second login round-trip after the post-auth
redirect, which would not be silent given prompt:"select_account".
ProtectedRoute now triggers MSAL login itself for never-authed users so
the cross-origin nav lands cleanly on /dashboard.
Backend deeplinks (session notifications, Teams alert) now point at
portal. Bootstrap URL switches to the apex shortform
(autopilotmonitor.com/go/{code}) since Strato 301-redirects to www
preserving the path. PowerShell bootstrap output points users at portal
for progress.
SEO metadata, sitemap, robots, OG and SLA/docs links remain on www.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-reseal agent_started + its agent_version_check are part of the
User Enrollment block in the user's mental model, but the resumed-marker-based
split kept them inside Part 1 because whiteglove_resumed is emitted ~0-2s
after the boot. Walk back from whiteglove_resumed to the agent_started that
sits between Part-1 close and resume, and split there. Falls back to the
resumed marker when the boot event isn't present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds EntraDeviceCertHelper that reads the AAD-Join device certificate's
TenantId extension (OID 1.2.840.113556.1.5.284.5) from LocalMachine\My.
Wired as the 5th and final synchronous probe in TenantIdResolver, after
the four registry probes:
1. enrollments_registry (Type=6, Intune MDM)
2. enrollments_registry_fallback (any Type, AADTenantID present)
3. cloud_domain_join_tenant_info
4. cloud_domain_join_join_info
5. ms_organization_access_cert <- new
Targets the Autopilot pre-provisioning / hybrid window where the cert
can land before the CloudDomainJoin registry writes complete. Lets the
agent boot without falling through to the registry-watcher awaiter and
burning the 600s wait window.
Helper hardening over the original draft:
- Filters to currently-valid certs (NotBefore <= now <= NotAfter) and
picks latest NotAfter when multiple match, defending against an
expired sibling from a prior re-enrollment.
- If the highest-priority cert lacks the OID extension, walks through
the remaining candidates instead of bailing on the first miss.
- Logger emits Info on hit (thumbprint + NotAfter) and Warning on miss
with the specific reason (no cert / extension missing / parse failed).
Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-pass scan of HKLM\SOFTWARE\Microsoft\Enrollments: pass 1 keeps the
existing Type=6 (Intune MDM) preference, pass 2 falls back to any
non-Type-6 sub-key carrying a non-empty AADTenantID. Targets the
Autopilot pre-provisioning / hybrid window where Type=6 sits at
EnrollmentState=1 with AADTenantID still missing while a Type=26
AAD-Join entry already carries the TenantID. Observed at customer
where the agent burned ~30min waiting for CloudDomainJoin\TenantInfo
instead of resolving immediately from Type=26.
Captures the enrollment UPN and classifies it. Known Autopilot
pre-provisioning placeholders (fooUser@... Intune-MDM leg,
autopilot@... Entra-Join leg, both generated by the Windows
MakeFakeUserEmail code path) are logged in full with annotation;
anything else is treated as a real user UPN and redacted to
<redacted_real_user_upn> since UPNs are PII.
Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three frontend hooks fetched /api/config/{tenantId} from non-admin contexts
(dashboard banner, session detail UI flags, gather-rules validation indicator)
and silently 403'd for Operator and Viewer because the endpoint is gated
TenantAdminOrGA — same anti-pattern as the tenant notifications bell.
The full /api/config/{tenantId} response cannot be relaxed because it carries
admin-only data (SAS URLs, webhook URLs). Instead, the already-MemberRead
/api/config/{tenantId}/feature-flags endpoint is extended with the five UI
display flags those hooks actually need:
- validateAutopilotDevice (Autopilot Device Validation banner)
- showScriptOutput (session PowerShell output visibility)
- enableSoftwareInventoryAnalyzer (session analyzer flag)
- enableIntegrityBypassAnalyzer (session analyzer flag)
- unrestrictedMode (gather-rules validation indicator)
Nullable model fields are flattened to their agent-side defaults so the UI
does not need a second nullable layer. The projection lives in a static
BuildPayload helper for unit-testability.
Frontend hooks switched from api.config.tenant() to api.config.featureFlags();
gather-rules GA-override path keeps using globalConfig.tenant (GA-only).
Tests: 4 new xUnit cases including a sentinel that fails if SAS URLs or
webhook URLs ever leak into the payload. Backend 886/886 green, web 234/234.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tenant-bell endpoint silently 403'd every 60s for Operator/Viewer because the
GET was gated TenantAdminOrGA but the navbar shows the bell to every member.
Three real bugs were tangled in this:
- SLA breach + consecutive-failures notifications were emitted into the global
channel even though they are per-tenant data. Only Global Admins ever saw
them; the actual tenant got no in-app indication.
- Frontend polled /api/notifications for any authenticated user regardless of
role and silently swallowed the 403 — masking the underlying mis-gate.
- Hardware-rejection notifications would have leaked admin-only context (link
to whitelist editor) once the policy relaxed.
Fixes:
- New TenantNotificationAudienceCatalog (Type to Member/Admin map) drives
per-type visibility server-side. Unknown types fail closed to Admin so a
newly added type does not leak to non-admin members until registered.
- GET /api/notifications relaxed from TenantAdminOrGA to MemberRead. Dismiss
endpoints stay TenantAdminOrGA (tenant-shared dismiss; per-user dismiss
deferred with TODO + memory note).
- SLA breach + consecutive-failures emitters moved to TenantNotificationService.
GlobalNotificationService injection removed from SlaBreachEvaluationService
entirely (we do not use it anymore, so it is gone).
- Frontend polls only when user has a tenant role or is Global Admin; dismiss
buttons hidden for Operator/Viewer.
Tests: 6 new xUnit, 7 new vitest. Backend 873/873 green, web 234/234 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical: GET /api/metrics/mcp-usage/user/{userId} (TenantAdminOrGA, no
TenantScoping) handed the oid to the repo without checking that returned
records belong to the caller's tenant. A tenant admin from tenant A could
read MCP usage records of any oid — including oids in tenant B.
McpUsageMetricsFunction.GetUserUsage now blocks 403 CrossTenantAccessDenied
when records are tenant-foreign and caller is not a Global Admin. The pure
decision logic lives in UsageCrossTenantGuard for unit-testability (9 tests
covering own/foreign/mixed/GA/empty/null/case-insensitive/empty-tid paths).
Also:
- MCP get_api_usage tool was calling /api/metrics/mcp-usage/{userId}
(without /user/), which is unregistered and returned 404 — corrected.
- list_blocked_devices description honestly states the endpoint is
GA-only on both variants (catalog policy, no change to the gate).
- days clamp [1, 365] added to /metrics/{app, geographic, geographic/sessions}
for parity with /metrics/summary, /metrics/usage, /metrics/platform.
- get_geographic_metrics, get_geographic_sessions zod schemas now constrain
days to int().min(1).max(365) — same pattern as the other tools.
- search/sessions, search/sessions-by-event, search/sessions-by-cve switched
limit handling from Math.Min(lim, 100) to Math.Clamp(lim, 1, 100); the old
form let a negative ?limit=-5 reach the repo.
Audit static analysis confirmed all other tenant-scoped endpoints route
tenantId through TenantHelper.GetTenantId(req) (JWT-only, ignores query),
so ?tenantId=<other> is silently dropped. Live-verified with an Operator
token: 14 cross-tenant query probes returned only the caller's tenant,
13 GlobalAdminOnly routes returned 403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
V2 emits enrollment_failed with Phase=Unknown (per feedback_phase_strategy),
so currentPhase.HasValue was false in UpdateSessionStatusAsync — leaving
Sessions.CurrentPhase at -1 on Failed sessions even though decisionState
already knew the last observed phase. UI PhaseTimeline could not render a
Failed marker. Mirror the existing Succeeded => Complete override in both
the normal and the post-ETag-exhaustion force-write path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replay-safety fix for two related defects that surface when the agent
reads accumulated CMTrace / Shell-Core log content on first boot
(per-enrollment lifecycle, fresh install).
PR1 — IME adapter (SignalAdapters/ImeLogTrackerAdapter.cs)
Every emit site (EspPhaseChanged, AppState, UserSessionCompleted,
WhiteGloveSealing, ImeAgentVersion, DoTelemetry, ScriptCompleted)
now stamps DecisionSignal.OccurredAtUtc and the InformationalEvent
with the CMTrace log entry timestamp the tracker captures into
LastMatchedLogTimestamp. Falls back to clock when no source time is
available; tags the event with derivedTimestamp=true on fallback.
Pathological clamp: source TS older than 24h or in the future >1h
is rejected, original preserved as rejectedSourceTimestamp.
PR2 — Shell-Core backfill + EspAndHelloTrackerAdapter
ShellCoreTracker.HandleBackfillRecord now propagates record.TimeCreated
via a new LastEventOccurredAtUtc property on the tracker; the
EspAndHelloTracker coordinator mirrors this property in its event
forwarders. EspAndHelloTrackerAdapter reads the coordinator value at
every Post site (HelloResolved, FinalizingSetup, WhiteGloveSuccess,
EspTerminalFailure, DeviceSetupComplete, HelloPolicyDetected) so
backfilled events carry source time, not "agent woke up just now".
PR3 — DecisionState.AgentBootUtc + boot-anchor guard
Without this guard, PR1+PR2 are a footgun: replayed signals carry
historical timestamps, and the reducer sites that arm deadlines as
signal.OccurredAtUtc + window would produce due-times in the past,
collapsing the Hello-safety window, device-only ESP detection,
finalizing grace, and classifier-tick to immediate fires at boot.
Added DecisionState.AgentBootUtc + DecisionEngine.EffectiveDeadlineBase
helper that floors the arming base at the boot anchor. Applied at all
six reducer sites (HelloSafety, DeviceOnlyEspDetection, FinalizingGrace,
ClassifierTick @ SessionStarted + AttachWG + re-arm).
EnrollmentOrchestrator.Start() stamps AgentBootUtc from the live IClock
on initial state and re-stamps it on snapshot rehydration so the prior
run's anchor doesn't bleed into deadline math after restart. Backward
compat: legacy snapshots without the field deserialize with null and
fall back to signal.OccurredAtUtc — pre-fix behavior preserved.
Tests: 287/287 DecisionCore + 1007/1007 V2.Core + 862/862 Functions +
530/530 Agent.Core. New: BootAnchorDeadlineFloorTests (5) +
ImeLogTrackerAdapterSourceTimestampTests (6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The on-disk final-status.json and the on-the-wire enrollment_complete audit
trail both project the same DecisionState into a "which signals were observed"
view, but did so via two independent code paths that drifted: AAD device-only
joins were dropped entirely from final-status.json, ShellCore + IME pattern
strings differed in spelling, and account_setup_entered was missing client-side.
Extract the census into a shared DecisionStateSignalCensus helper in DecisionCore
so adding a new milestone signal lights it up in both surfaces automatically.
Final-status.json gains a top-level imeMatchedPatternId mirror so the IME pattern
id survives the format switch from "ime_pattern:{id}" to "ime_pattern_matched".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recursive AgentState walk in DiagnosticsPackageService already captures
StateArchiver's `.part1-<utc>/` buckets on WG Part-2 boots, but no test
asserts it. A future refactor that flips includeSubfolders to false or
trims StateFilePatterns would silently drop the Part-1 forensic snapshot
from the diagnostics ZIP.
Adds a lock-test that creates the four bucket files (snapshot/signal-log/
journal/reason) and asserts they round-trip under AgentState/.part1-…/.
Also extends the Section 4 comment to name `.part1-<utc>/` alongside
`.quarantine` as the second intended subfolder consumer of the recursion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.
## WG Part-2 architectural cleanup (PR-A through PR-E)
Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.
The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).
V2 is now V1-symmetric:
PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
first-boot through the normal recovery branches, and posts a single
whiteglove_resumed InformationalEvent after the onIngressReady hook.
New helper class StateArchiver.cs + StateArchiverTests +
EnrollmentOrchestratorPart2EmissionTests.
PR-B — DecisionCore Cleanup. Removed:
- DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
- SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
- SessionOutcome.WhiteGlovePart2Complete
- DeadlineNames.WhiteGlovePart2Safety
- DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
- WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
- DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
- HandleSessionRecoveredV1 dispatcher + handler
PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
AadJoinHost is the only production caller using a named-arg form,
unaffected.
PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
(IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
the dead `UserAadSignInComplete` constant from Shared.Constants.
Extracted pure helper computeWhiteGloveSplitSequence(events) in
app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
utils/sessionExportUtils now share it. New 6-case vitest covering
splitter behaviour (no marker, primary trigger via whiteglove_resumed,
agent_started fallback, Pending-only variants, primary-vs-fallback
race). PhaseTimeline + eventTypes were already on the new model.
PR-E — Tests + Fixtures Final Cleanup. Deleted the two
whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
removed dead `whiteglove_part2` case + test from SummaryDialog
OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
removed symbol names so the final grep is clean. Updated MEMORY
entries (project_v2_completion_location, project_wg_part2_simple_model,
project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
MEMORY.md index).
What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.
Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.
## Resume-path correctness fixes (Codex review of the cleanup)
Three findings on the new resume mechanic:
P1a — AgentRuntimeHost was still skipping LifecycleEmitters
PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
After PR-A's archive-and-reset the decision state is empty, so the
Classic flow needs both anchors (otherwise ScenarioProfile stays at
default and the Hello+Desktop AND-gate misclassifies). Fixed: post
unconditionally for facts; SessionStarted is now only gated by an
AdminAction, not by the resume flag. The orchestrator's
whiteglove_resumed lands first (smaller sequence) so the Web splitter
(`splitSequence = resumed.sequence - 1`) still puts SessionStarted
into the Part-2 timeline block.
P1b — StateArchiver was archiving event-sequence.json along with the
reducer state. Part 2 reuses the same SessionId and the backend orders
events session-wide by (SessionId, Sequence); the Web split also uses
`splitSequence = resumed.sequence - 1`. Resetting the counter at the
resume boundary would collide Part-1 and Part-2 sequences and break
the splitter. event-sequence.json is now intentionally preserved; the
three reducer-state segments still archive. StateArchiverTests +
RecoveryPathTests asserting the new contract.
P2 — V1 reset the local session-age clock on Part-2 resume
(SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
V2 was clearing only the WhiteGlove marker, so a crash/reboot during
Part-2 AccountSetup would let the next start run the emergency-break
watchdog against the original Part-1 timestamp and trip too early.
AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
(DateTime.UtcNow) right after ClearWhiteGloveComplete. New
ProgramGuardsTests test asserting the V1-symmetric resume contract.
## Install-mode handoff hardening
Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.
PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
install-time runtime handoff. schtasks /Run reports SUCCESS as soon
as the run is queued; the same battery / AV / OOBE-defer paths that
motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
task stuck in Queued state). WMI also gives the runtime process the
same isolation Task Scheduler uses, without the Job-Object inheritance
risk of a direct Process.Start.
PR2 — Register the BootTrigger Scheduled Task from a hardened XML
definition rather than `schtasks /Create`. The CLI form inherits XML
schema defaults DisallowStartIfOnBatteries=true and
StopIfGoingOnBatteries=true, which queue the BootTrigger run
indefinitely on a laptop that boots on battery in OOBE / WinPE. The
XML disables both, sets StartWhenAvailable, and pins
ExecutionTimeLimit=PT0S so the long-running agent is never killed at
the schtasks 72h CLI default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WhiteGlove Part 1 sealing has no end-user session and no completed app
installs, so launching the summary dialog there either lands invisibly
in SYSTEM context (about to be reseal-rebooted) or fails outright; the
enrollment_summary_shown event was semantically wrong and polluting the
timeline. Gate RunBuildAndLaunchDialog (and the state==null warn-only
branch) behind !isWhiteGlovePart1; Completed / Failed /
WhiteGloveCompletedPart2 paths unchanged.
Plan reference: bdb3cf9d-wg-part1-part2-followups.md PR-X1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AadJoinWatcherAdapter never wrote SignalPayloadKeys.AadJoinedWithUser into the
decision-signal payload, so HandleAadUserJoinedLateV1 always read withUser=false.
Every Classic AAD-Join session ended up with aad_user_joined_device_only in the
audit trail and a wrongly-flipped WhiteGloveSealingClassifier weight. The watcher
only fires AadUserJoined for non-placeholder accounts (placeholders go via the
separate PlaceholderUserDetected event), so the flag is unconditionally true at
emit time.
Cosmetic: rename the wire EventType from aad_user_joined_late to
aad_user_joined_observed. The "late" qualifier reflected the agent's observation
perspective ("I missed the live event"), which holds for 100% of Classic sessions
and reads as misleading in the timeline (the event lands seconds after agent
start, before aad_join_status). FailureSnapshotBuilder accepts both strings — V1
agents and historical data still carry the legacy name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan §A + §B (Zero-Periodicity Observability, 2026-05-03). Render the per-
anchor DecisionState snapshots that PR2 wrote into Event.Data, with the
Death-Rattle dual-snapshot special case that PR3 produces.
- New "Lifecycle Anchors" tab in /sessions/{id}/inspector — one row per
event carrying data.decisionState, oldest-first, filterable by event
type. Each row expands into a DecisionStateCard showing stage /
stepIndex / lastAppliedSignalOrdinal / scenario block / facts table
with {value, ordinal} per fact / activeDeadlines / forwards-compat
"Other fields" bucket.
- Death-Rattle special case: prior_run_died_with_state events render
TWO cards side-by-side — "Prior run state (at death)" (priorState)
on top, "Fresh run state (post-recovery)" (decisionState) below —
plus a metadata strip with previousExitType, lastBootUtc, and the
optional crashException. The pairing is the load-bearing diagnostic:
delta between the two reads as the recovery delta.
- FailureSnapshotBlock on the session-detail page now links into the
Inspector at ?tab=anchors so operators reading a 5h-timeout snapshot
can jump straight to the anchor stream. SearchParam-driven tab
initialisation in inspector/page.tsx (single read on mount; clicks
stay client-state, no history churn).
- Schema-driven anchor filter: an event is an "anchor" iff
data.decisionState is a non-null object — mirrors what the agent
emitted exactly even if the C# allowlist evolves between agent
versions. 13 vitest cases pin the predicate (Death-Rattle present
and Death-Rattle missing-decisionState; primitive / null / undefined
decisionState; sequence sort with fail-soft for missing field).
- Inspector remains behind requireGlobalAdmin (M6 gate) — no new
permission surface.
Test stand: vitest 221 passed (15 files, +13 cases); tsc --noEmit clean.
Codex review on PR3: the AgentRuntimeHost integration (gate + path build +
pre-Start read) is the fragile part of the plan and was only tested
indirectly through the helper + emitter paths. A regress like
"TryReadRaw moved to after Start", "WhiteGlove resume not skipped" or
"clean exit fires" would have slipped through.
Pull the gate / path / read sequence into a dedicated DeathRattlePrelude
in V2.Core so it can be exercised in isolation:
- DeathRattlePrelude.TryCapture(stateDirectory, previousExitType,
isWhiteGloveResume, logger) returns the prior snapshot or null;
IsUncleanExit exposed for callers that want to log the decision.
- AgentRuntimeHost reduced to a single TryCapture call; private
IsUncleanExit + System.IO using removed.
- DeathRattlePreludeTests (24 cases) cover:
- Clean-exit table (clean / first_run / unknown / null) — all skip,
even with snapshot present.
- Unclean-exit table (reboot_kill / hard_kill / exception_crash,
mixed casing) — all read.
- WhiteGlove Part-2 resume gate wins over unclean exit.
- Snapshot missing / corrupt → null, never throws.
- Path-layout pin: prelude reads the same hardcoded "snapshot.json"
filename the orchestrator writes to.
- Non-mutation contract: byte-level equality before/after, file
still loadable via SnapshotPersistence.Load(), no .quarantine
directory created (orchestrator owns the quarantine decision).
- Argument validation (null logger / null+empty stateDirectory).
Test stand: V2.Core 983 (+24).
Plan §B (Zero-Periodicity Observability, 2026-05-03). On the next agent run
after an unclean exit (reboot_kill / hard_kill / exception_crash), read the
prior run's last persisted snapshot BEFORE orchestrator.Start() and emit a
single prior_run_died_with_state event in the start-callback so post-mortem
diagnosis sees what the dying run last knew — without relying on client-side
log uploads.
Coupling with PR2 (Plan §A): the event itself is on the lifecycle-anchor
allowlist, so EventTimelineEmitter automatically attaches the FRESH run's
data["decisionState"] alongside the data["priorState"] we set here. Both
views (persisted-at-death vs post-recovery reconstructed engine state)
land in one wire payload.
Skip conditions: WhiteGlove Part-2 resume (planned exit, not a death),
clean / first_run exits, snapshot missing or corrupt (TryReadRaw returns
null on every failure mode, agent keeps running).
Changes:
- SnapshotPersistence: extracted TryReadInternal as shared parser; added
public static TryReadRaw(path) — quarantine-free static reader the agent
can call before Start without disturbing the orchestrator's recovery
pipeline. Failure-equivalence with Load() pinned by reader-parity test.
- LifecycleEmitters.PostPriorRunDiedWithState: builds Warning + immediate-
upload event with previousExitType (defensive null→"unknown" fallback),
lastBootUtc, optional crashExceptionType, and priorState (Plan §A
snapshot dict). Swallows ingress exceptions to keep the agent_started
→ version_check chain robust.
- AgentRuntimeHost: pre-Start TryReadRaw block + IsUncleanExit gate +
in-callback PostPriorRunDiedWithState immediately after the existing
agent_started / version_check / unrestricted-mode anchors.
- Tests: 9 SnapshotPersistence (TryReadRaw + reader-parity + no-quarantine
contract); 8 LifecycleEmittersPriorRunDied (event shape, wire policy,
priorState schema, crash-exception inclusion, null-ExitType fallback,
message contents, ingress-exception swallow).
Test stand: V2.Core 959 (+17), DecisionCore 293, Backend 862, V1 530.
Plan §A enrichment (Edge-Triggered State Snapshots, 2026-05-03). For
events on the lifecycle-anchor allowlist, EventTimelineEmitter.Emit
now attaches the current DecisionState snapshot under data["decisionState"]
after ResolveData(). Existing Data fields are always preserved — both
the parameter-rebuilt path and the typedPayload-merged path mutate a
fresh dict, so adding the new key is a strict merge, not a replace.
Behavior:
- 13 anchor event types get decisionState (full allowlist from PR1's
LifecycleAnchorEventTypes).
- Non-anchor events (enrollment_complete/_failed, app_install_*,
performance_snapshot, download_progress) are unchanged.
- Idempotent: an existing data["decisionState"] (defensive — should
never occur in practice since only the emitter writes that key) is
not clobbered.
Why Emitter, not Reducer (per Codex rev2 review):
- EffectRunner.cs:114 already passes stateAfterReduce to the emitter,
so no new dependency is needed.
- Reducer-side enrichment via typedPayload would have replaced existing
Data via the typedPayload-as-dict branch in ResolveData — wiping
espPhase, patternId, network state etc. The emitter-seam is the only
place that runs after ResolveData and sees a dict ready to merge into.
Tests (V2.Core +22 tests, suite 920 → 942):
- All 13 anchor event types via Theory: each gets decisionState with
schemaVersion matching the builder's const.
- Parameter-based path: esp_phase_changed preserves reason+espPhase.
- typedPayload path: network_state_change preserves from/to/transition.
- Idempotence: prebuilt sentinel decisionState in typedPayload survives.
- Non-anchor exclusion: 5 representative non-anchors confirmed null.
- Top-level allowlist sanity-check (delegates full coverage to PR1's
DecisionStateSnapshotBuilderTests).
Tests (regression-clean across all suites):
- V2.Core: 920 → 942 (+22)
- DecisionCore: 293 (no change)
- Backend Functions: 862 (no change)
- Existing EventTimelineEmitterTests: 26/26 still pass.
Plan: .claude/plans/v2-zero-periodicity-observability.md (rev7).
PR3 (Death-Rattle / prior_run_died_with_state) is next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>