A session without a Sessions row can never be reached by the cascade, so every row written for it was an orphan by construction. Three layers now keep the platform clean:
- Prevent: IngestTelemetry answers a batch for an unknown session with 410 (same status and body as the cascade lock; request-row marker IngestRefusal = session_unknown | cascade_locked). The derived writers - vulnerability correlation, analyze, manual rescan - stop on SessionDeletionGuard.GetLivenessAsync != Live. The agent treats every 410 as terminal: one TelemetryRejectedSessionUnknown report (exempt from the reporter cooldown), then terminate_session with forced self-destruct (origin session_gone, no final drain, no diagnostics upload); spool items of a foreign session id are dropped at startup and never terminate the current session.
- Detect: OrphanSessionSweepFunction (every 4h at :45, own lease) diffs the Sessions keys against the EventSessionIndex handles - two range drains instead of ~16.8k Sessions point reads per 2h maintenance run - and re-checks every candidate (handle unchanged, row still absent, tombstone) moments before deleting. The in-maintenance sweep raced the retention cascade on their shared 00:00/12:00 tick and reported false orphans; it is gone.
- Clean: every table of the cascade manifest minus the tombstone pair (ratchet in TableLifecycleBucketTests), the inventory counters with the stamp on the handle (claim, then decrement), the handle last with If-Match. A weekly read-only reconciliation of EventTypeIndex against the Sessions keys (Sunday mornings UTC, and on every manual maintenance run) reports residue.
MaintenanceCompleted now carries stepsMs. MCP search_sessions_by_event auto-exhausts empty-but-continuable pages. Manifests regenerated for the new ops event types and AdminConfiguration.OrphanDeepCheckLastRunUtc.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Customer domains, tenant-ID fragments, two full customer tenant GUIDs and
four real session GUIDs had found their way into code comments, test
constants and a fixture comment. They are replaced by neutral descriptions
("field case", "kiosk tenant") and synthetic values; no behaviour changes.
A check of every onboarded tenant's domain and ID prefix against the tracked
files finds no customer identifier left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The retention fanout reads the tenant configuration strictly. A storage
failure used to yield the synthesized default (90 days), which deletes
sessions and prunes rows a Pro-365 or never-delete tenant keeps. Now the
tenant is skipped and counted: tenantsFailed on
SessionDeletionMaintenanceCompleted, which turns Warning. A row that
vanished since the tenant listing is skipped too, instead of being
recreated with defaults.
- GetDeviceHistoryAsync throws on a read failure instead of returning "no
history". Both writers merge into the chain they read and replace the row,
so the old behaviour dropped every older ref. The sweep skips such devices
and leaves the FTR aggregates untouched for that tick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TableKeySanitizer replaces ten private and inline copies of the key rule
(/, \, #, ? become _) and also removes control characters, which Table
Storage rejects in keys and the HTTP layer rejects in a point read's URL.
Keys without control characters are unchanged; a source ratchet forbids new
copies. The dead UpsertSoftwareInventoryAsync is removed.
- VulnerabilityInventoryLoader hands out inventory text without control
characters, so CPE lookup, report text, side-row identity and counter keys
all see the same text.
- A correlation run whose SoftwareInventory counter writes or contributions
side-row fail writes one SoftwareInventoryCounterWriteFailed ops event
(Maintenance, Warning; counts, error type and status, up to five sample
keys) instead of a per-key entry in the tenant audit trail. The counter work
is awaited inside the invocation instead of a detached Task.Run.
- The new type is listed in the alert-rule catalog; shared manifest and MCP
vocabulary regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some installers pad REG_SZ values with NULs. .NET Framework drops only one
terminator, so the rest rode into the normalized software identity and,
server-side, into a table key whose point read the HTTP layer rejects
("400 Invalid URL"). DisplayName, DisplayVersion, Publisher, InstallDate,
InstallLocation, UninstallString and ParentKeyName are now read the way
Windows shows them; a DisplayName of NULs only counts as empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fleet Health, the Software hub (Installs, Vulnerabilities), app details, Geographic
Performance and its drill-down, Usage Metrics and Analyze Rules read ?days= through one hook
(useWindowDays). The URL is the only state: a preset click writes it with replaceState, the
page default stays out of it, and a link to another app on the same route refetches. Pro
reaches up to 365 days through the URL alone; the presets stay 7/30/90 and a value outside
them highlights none.
Cap: platform roles 365 in every scope, everyone else the viewed tenant's edition cap from its
feature flags (Community 90, Pro 365), read only when the URL asks for more than 90 days. The
cap never rewrites the URL. Fleet Health draws weekly bars beyond 90 days, the Analyze Rules
sparkline keeps its width and bundles days beyond 30, long windows keep their own progress
estimate. The duration-regression bell's days=35 link no longer snaps to 30.
The string presets and their six fall-through mappers are gone; Usage Metrics passes days to
the API, the Software hub and Geographic Performance share their defaults with the pages that
link back to them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First-time-right, time-attribution and rule-statistics rows lived on fixed lifetimes (180,
180 and 90 days), a storage bound reused from the never-pruned tables, never weighed against
tenant retention: a Community tenant could read first-time-right rows for days 91-180 through
the API and MCP, a Pro tenant keeping 365 days lost them after 180 and its rule statistics
after 90.
The retention fanout now prunes per tenant, before its session scan: these rows at the
tenant's effective retention (downgrade grace included), the audit trail at
max(180, retention) up to 365. The maintenance cleanups become outer bounds and platform
lifetimes in DataLifetimes: no tenant row beyond 365 days, global first-time-right rows 365,
time attribution 180, global and legacy rule statistics 90, the platform audit partition and
legacy audit rows 180. Device histories already follow their sessions and stay as they are.
The device-journeys window goes to 365 days (route cap and MCP get_device_history). The
SessionDeletionMaintenanceCompleted ops event carries tenantRowsPruned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tenant that is Pro only through its managing organization ("Pro (MSP)") could not save a
data retention above 90 days: the patch and revert paths validated against the raw table row,
which never carries the read-time projection, so the edition resolved to Community while the
feature flags and the settings slider offered 365.
The validator now gets a projected copy of the stored row. The row itself stays
projection-free, because the exactly-these-fields verify diffs against it; a projected row
would read as drift and roll every save back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome and Edge run the timers of a tab hidden for five minutes once a minute; the client then misses
its keep-alive, the service closes the connection and the automatic reconnect restores it, a loop every
minute in every background tab. lib/signalrPark.ts, a pure state machine tested with an injected clock,
parks the shared connection after four minutes hidden and resumes it on the first visible signal. The
resume ends a gap, so every view re-reads through useSignalRResync. A failed resume retries with backoff
up to 30 s until the next park; no park starts during an accessRevoked restart. Telemetry:
signalr_parked { hiddenMs } and signalr_resumed { parkedMs, transport }; park and resume emit neither
signalr_disconnected nor signalr_connected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pushes only announce changes, so after a gap a view has to check the backend. Only some did: the
bells re-fetched from connection.onreconnected (handlers piled up, nothing after a restart), the
Progress Portal never re-read (a page left open across the end of an enrollment kept its last
state), and the session page re-read only its events. The provider now counts the ends of gaps
(a reconnect after its rejoin, a start of a connection that was up before) in resyncEpoch, and
useSignalRResync(onResync) fires on each once the group joins of that moment have settled, so a
push sent between a re-read and its rejoin cannot get lost; a joinGroup that finds a join of the
same group in flight now awaits it. Bells, Progress Portal (summary and events), session page
(session, analysis, vulnerability report; events keep the join catch-up), diagnosis page,
dashboard list, dashboard stats and fleet health use it; the wasConnectedRef checks are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host decodes %2F to a path separator before routing, so apps/{appName}/analytics|sessions and
their global twins answered 404 for every app whose name contains '/', before any function ran;
a name with a literal escape such as '%20' was decoded a second time. The routes are now
apps/analytics, apps/sessions, global/apps/analytics and global/apps/sessions with ?appName=, and
the unblock route is DELETE devices/block?serialNumber=&tenantId= (serials may contain '/' too).
Function names, policies and tenant scoping stay; req.Query decodes exactly once. Tests run names
with '/', '%', '+', a space and a literal escape through the catalog and the handlers, and the web
builders put the name into the query. Manifest and wire types regenerated (doc text only).
Rollout: backend, then web right after; an app-detail tab on the old bundle 404s until reloaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The typeahead event sent the raw search text (serial numbers, device names) as a custom
dimension. It now sends queryLength and resultCount as measurements, like session_searched, plus
guidShaped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A member without a tenant role who opened a shared session link landed on a session page whose
every read answered 403: an empty page that looked like a valid session without data, its two
error toasts in a bell this user's navbar does not show. ProtectedRoute takes a new opt-in
requireTenantReadScope (the Progress Portal hint's definition of "no role"); the session page sets
it on all three branches and keeps its data hooks inert once auth/me shows no read scope (before
that nothing changes, so members are never delayed). The full-page notice says whom to ask, links
the Progress Portal and the docs article, and reports tenant_role_missing_shown { path } once per
mount.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A receiver holds one pairing, so a Global Admin whose phone is paired in the platform scope
could not get their own tenant's enrollment alerts without replacing that pairing. The push
transport now resolves a tenant channel's recipients as the tenant's Active devices plus the
Active platform devices whose OwnerHomeTenantId is that tenant, filtered by the platform
eligibility rule and deduplicated by endpoint; no other tenant reaches a platform device, there
is no opt-in and no setting. The home tenant's device list shows such a row with
PushDeviceDto.Scope = "platform" (manifest and wire types regenerated); the panel marks it with
a Platform badge and no actions — it is managed under the platform's device list, and the
tenant routes do not know the id. Tests: fan-out incl. a foreign home tenant, a former Global
Admin and a shared endpoint; the list in the home tenant, the owner's and an operator's view,
and the refusing tenant routes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The section is for troubleshooting, not for every visit: a disclosure button in the heading
opens it, the diagnostics are collected the first time it opens (not on page load), Refresh
shows only while open, and a one-line note under the import button says what it does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every session-watch push carried the tag "session_watch" (SystemMessage set Tag = type), so a
browser that honours tags — Edge on the Windows device, iOS for notifications the worker shows
itself — replaced an earlier completion with the next one; two watched sessions finishing
minutes apart (18:38 and 18:40 UTC today) would have shown as one. The tag is now the payload
id: a test, a pairing or a session watch never replaces another notification. Alerts keep
their per-session tag, and the history is keyed by id and keeps every push either way.
The iPhone kept running the worker without the navigate fix through two opens: a home-screen
app that is only resumed never navigates, and the browser checks for a new worker on
navigation. reconcileDevice now calls registration.update() on every open (fire-and-forget).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The worker trace named the error: "TypeError: Call to showNotification() while handling a
push event did not include NotificationOptions that specify a valid defaultAction url".
WebKit (ServiceWorkerRegistration.cpp) rejects showNotification() inside a declarative push
event unless the options carry a valid navigate URL; a notification shown with one is not
displayed directly but replaces the proposed notification when the event ends. Every
displayed notification on the iPhone therefore came from the platform, with data null, which
is why the on-open import recognised none of them.
toNotificationOptions now sets navigate: the payload's deep link, else the history URL on
the worker's origin (opened on the entry); the generic fallback carries the history URL too.
Trace values may be 240 characters so an error text is readable in full. Tests pin the
member on the entry, the generic and the fallback call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first worker trace from the iPhone read "push · notification · session_watch · added ·
display failed": the worker ran and wrote the entry, but both showNotification calls threw —
on iOS the platform shows the proposed notification itself inside a declarative push event.
In the old code show()'s rejection made the event's waitUntil reject at once, with the history
write still in flight; that is the plausible cause of the entries lost today. show() now
resolves to "shown", "fallback" or "failed: <error>" and never rejects (regression test with a
throwing showNotification).
The same trace showed all six displayed notifications as "not ours": getNotifications() on iOS
hands them back without a data object the entry rule recognises, so the on-open import cannot
identify them. The Diagnostics list now shows tag, data shape and timestamp per notification
so an import without data can be designed from evidence; a data member that arrives as a JSON
string is read as JSON.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two session-watch pushes reached the iPhone's lock screen today (PushDelivery 201, the same
payload shape as the test pushes that do arrive) and neither reached the history, nor did the
import of displayed notifications on open find anything. The device holds the evidence, so the
worker now records what it did: one record per push (which event member carried the payload,
type, id, outcome of the history write, how the notification was shown), per click, per
activation and per subscription repair — in meta `workerTrace`, 25 records, values capped,
written inside the same bounded budget as the history write (a database that never answers
leaves no record, which is itself the finding). The status page shows it under Diagnostics:
worker state, notifications still displayed, history rows, the trace, and a manual "Import
displayed notifications" with its count. Nothing leaves the device.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A session-watch push showed on the iPhone but never reached the history, and a pull on the
empty part of the page did nothing. (1) The history now heals itself: on every load the page
asks the service-worker registration for the notifications still in the notification centre
and writes the ones it lacks (entryFromNotification, the rule the click handler already used,
now in the worker core) — a push the worker could not persist is caught up as long as its
notification has not been cleared. (2) The pull gesture is registered on the window (passive),
not on the content wrapper, so a pull that starts below the list counts; the wrapper keeps a
minimum height. Tests pin the entry rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Get started keeps its tick as the acceptance: the CTA hands ?signupConsent=1 to portal (kept per
tab for 30 minutes, removed from the address bar at once), the first auth/me forwards it, and the
onboarding write records DpaVersion together with DpaAcceptedBy (first-login UPN) and
DpaAcceptedAt.
- Any other first sign-in (the navbar "Sign in") sets DpaAcceptancePending on the new row instead.
auth/me carries the flag; ProtectedRoute renders a blocking dialog with the same sentence and
links as Get started (shared TermsDpaAgreementText) instead of the page; POST
auth/dpa-acceptance writes the acceptance for the caller's own tenant only, a no-op 204 when
nothing is pending.
- Tenants onboarded before this carry no flag and are never asked. Activation is unchanged; the
dialog appears after it.
- The three new fields are system-owned like OnboardedBy (patch deny-list, revert-protected) and
projected by config/all, the GA tenant report and the MCP tenant list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both mails were landing in Junk at some recipients although the domain's SPF, DKIM and DMARC
are in order, so the copy loses its marketing markers: the welcome subject names the tenant
("Your Autopilot Monitor access for {domain} is ready", no exclamation mark; the editor shows
it with the domain placeholder), the heading drops its exclamation mark, "Great news" and
"incredibly valuable ... don't hesitate" give way to plain sentences, the closing line is a
thank-you, and both templates keep GitHub as their only feedback link (the personal LinkedIn
link, a second foreign link domain, is gone). User go for the customer-visible wording.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both template mails also land in Junk at some recipients. The templates are already image-free
and inline-styled, so the remaining technical signals were the missing text/plain part (the
provider derived one) and the missing automation header. HtmlToText renders the effective
template (default or operator override) as the text part — head, style, script and comments
dropped, blocks and list items as lines, links with their URL, entities decoded — and
EmailService sends every mail with Auto-Submitted: auto-generated / X-Auto-Response-Suppress,
shared with the alert mails. Wording changes to the customer-visible templates are deliberately
not part of this commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first channel mail landed in Junk although SPF, DKIM (mte1/mte2) and DMARC are in order,
so the content was the tell: a bracketed, shouting subject built from the machine title
"Ops Alert: Platform/PushDeliveryFailed", a raw JSON block, hardly any prose, no text part.
Now: subject "Autopilot Monitor: Push delivery failed (Platform)" (event name as words, no
tags, no severity word), one introductory sentence naming the severity and category, the
event as a heading, facts and the opted-in payload as key/value tables (nested values as
compact JSON, non-object payloads still capped), a visible portal link, a footer that says
why the mail was sent, a plain-text part of our own (auto_text off for alerts) and the
RFC 3834 Auto-Submitted header. Template mails are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A test mail that Mandrill accepted did not arrive; the backend had logged nothing beyond
"accepted". SendViaMandrillAsync now returns the status (sent/queued/rejected) and the message
id, the channel test reports them ("Provider status: queued, id ..."), and the EmailDelivery
custom event carries them as status and providerIds — enough to look a mail up in the
provider's activity log.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WebhookProviderType.Email = 60 joins Telegram and Push behind the Global-Admin gate: it sends
under the platform's sender identity and provider key (the welcome/farewell path,
noreply@autopilotmonitor.com). The channel's Url names one to five plain recipient addresses
(ValidateEmailDestination, no SSRF URL check). EmailNotificationService renders the alert
(EmailAlertRenderer: severity-prefixed subject, escaped HTML with summary, facts, sections,
links, and the structured payload as preformatted text when the rule opted in) and sends one
message per recipient through EmailService.SendMessageAsync; outcomes land in the EmailDelivery
custom event and the log, never in an ops event (a failing e-mail channel must not feed itself).
The dispatcher takes the transport through a chained fourth-parameter constructor so the
existing mocks keep working. ChannelEditor offers the provider to Global Admins with a
"Recipients" field. Tests: routing, validation, gate, renderer, send against a provider stub.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted and RuleSubmissionReceived
(category Tenant, Info) are recorded where the backend pings the Telegram chat directly, so an
alert rule can route them to any channel — the Push channel included. Like FeedbackReceived,
message and details carry ids and a portal path only, never the submitter or a comment (the
user-id column keeps the submitter for the admin view and never reaches a channel). The ops
event is awaited before the response; the Telegram pings stay untouched until they are retired.
Dual-registered (constants, All, web rule list), vocabularies regenerated, health marker row.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- History page: one dropdown built from what the history holds — Platform (ops alerts, one
entry per Category fact), Enrollment (one per tenant alert kind) and This device (the
receiver's own messages) — with counts, a "12 of 48" readout and a clear button while a
filter is active, hidden while there is only one kind to show. The choice is stored on the
device (meta key historyFilter); a deep link to a hidden entry shows everything. A
customer's device holds tenant pushes only, so it never sees a Platform group.
- SessionWatchResponse.Available: GET/DELETE sessions/{id}/watch say whether this person can
watch at all (an enabled Push channel in one of their own scopes where they hold Admin or
Operator); the portal's "Notify me when done" renders only then, instead of letting a tenant
admin click into "enable a Push channel first", which only a Global Admin can do. An active
watch stays available so it can be switched off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A test push arrived as a notification while the history page was open, but the list did not
change until the user left the page and came back. The page now re-reads the local store every
5 s while it is visible, on focus and pageshow as well as on visibilitychange (the worker's
message stays the fast path), and an unchanged read keeps the list's identity so the poll never
re-renders for nothing. A pull from the top refreshes on demand — a home-screen app has no
native gesture — with a damped indicator (pull / release / refreshing) and the device reconcile
folded in; the gesture rule is a pure, tested controller.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Push receiver: delete entries, user-set retention, self-migrating VAPID key (B-y4z)
- History: a long press (or right-click) on an entry opens a small menu — Delete (local only;
the history never leaves the device) and Open in portal when the entry carries a link.
- Retention: the status page lets the user keep the history 0-365 days (default 30 as before,
0 = until the entry cap of 200, which is unchanged and stated); stored in the receiver's
IndexedDB meta, honoured by the worker and the pages when pruning.
- Key rotation is a re-registration, not a re-pair campaign: GET push/device names the active
kid and public key; a receiver whose stored kid differs subscribes again with the new key on
its next open and PUTs the new kid (reconcile action "rekey"); a Stale vapid_key_mismatch row
re-arms the same way. Operator texts (settings template, generator script) follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal, infra (push-relay addendum: delete, retention, key migration)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Push receiver: name a failed subscription repair; the worker re-subscribes with the server's active key
- reconcileDevice returns repairError when the subscribe() or the PUT after it failed (400
InvalidSubscription, 429, network); the status page shows "present, server not updated" plus
the reason instead of a silent "present". Retried on the next open as before.
- pushsubscriptionchange (Chrome, Firefox) asks GET push/device for the active key before it
re-subscribes (planSubscriptionKeys: active key when it differs from the stored kid or no key
is stored, else the stored key, else nothing), drops the browser's replacement subscription
after a rotation, PUTs the kid it used and stores kid + key; never a PUT with an empty kid.
Worker fetches are bounded to 10 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal (push-relay: worker key rule)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Push receiver: read the declarative notification from the push event (Safari history fix)
On an iPhone the pushes arrived with the right content, but every history entry was the generic
placeholder: with "mutable": true Safari (iOS >= 18.4) dispatches the regular push event with the
proposed Notification in event.notification and event.data null, and the worker read only
event.data. The push handler now takes event.notification first and shows the same content
itself (one notification, entry written at arrival); a tap on a notification the platform
displayed without the worker rebuilds the entry from the Notification's data, and the click
path's history write is bounded like the push path's. Tests pin both paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal (push-relay: Safari declarative-push finding)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Action pins: exact release tag in every version comment (D-331)
setup-node v7.1.0 and upload-artifact v7.0.2 moved the floating v7 tag, so the
"# v7" comments on our still-correct pins (v7.0.0, v7.0.1) stopped naming the
pinned commit and zizmor's ref-version-mismatch raised 11 code-scanning
alerts on main. The other five major-only comments (checkout, setup-dotnet,
cache, attest-build-provenance, functions-action) were one upstream release
away from the same alert.
All 38 major-only comments now carry the tag their unchanged SHA resolves to
(checkout v7.0.1, setup-dotnet v6.0.0, cache v6.1.0, setup-node v7.0.0,
upload-artifact v7.0.1, attest-build-provenance v4.2.2, functions-action
v1.5.7); no SHA moved, Dependabot keeps advancing SHA and comment together.
verify-pins now accepts only a vX.Y.Z comment and names the floating-major
case in its own error.
Verified: zizmor online (GH_TOKEN) reproduced the 11 findings before and
reports 0 ref-version-mismatch after, --min-severity low exits 0; the
verify-pins script passes all 51 uses lines and rejects a major-only comment,
a missing comment and a tag reference in a scratch workflow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (D-331 action pin comments)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (ingest-session-row-reads RELEASED, measurement)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Web Push channel: alerts to paired phones without a third-party messenger (D-327 to D-330)
Provider WebhookProviderType.Push = 50 in both channel scopes. It carries no destination;
NotificationChannelDispatcher takes a NotificationScope from every caller and the push
transport resolves the scope's paired devices at send time (table-backed Admin/Operator,
GlobalAdmin with matching identity binding; Viewer and Global Reader never).
The receiver never signs in: a signed-in Admin/Operator creates a one-shot pairing code
(11 Crockford chars, hashed, 10 min, ETag one-shot, five-failure burn), the phone redeems
it anonymously, the portal session confirms. The phone's only credential is the device
token in X-Push-Device-Token, good for status, re-subscribe and unpair of its own row
(10 calls per minute per device; the anonymous pairing routes 10 per minute per IP).
Payloads are RFC 8291 per device (new BCL-only library src/Push with the RFC vector pinned
byte for byte) with a VAPID key ring and the kid on every device row; the projection reads
an allow-list only (never Sections, DataJson, Failure Reason, Last Failure or the ops
message), serial masked, 2 KB. 404/410 mark a device Stale instead of deleting it and the
receiver re-arms it on its next open (the same PUT lifts a delivery-failures pause); devices
pause when their owner stops signing in (PushOwners stamp in auth/me) through one pause path
and resume by themselves; an owner without a stamp is paused, never deleted outright; role
loss revokes with a wipe push and deletes the row (there is no Revoked state). PushDevices
and PushPairingGrants are credential-bearing: raw-table deny-list, no backup, redacted
dependency rows.
Receiver app under /push/ (module service worker, Declarative Web Push shape, local history,
pairing and status pages, manifest + icons), portal UI (Push provider in the channel editor,
pairing dialog with QR code, device lists for tenant and platform scope, "Notify me when
done" on the session page — the watch row records the watcher's own scopes, so a Global
Admin watching a customer session is notified on the platform-paired phone), 22 catalogued
routes, ops events PushDeliveryFailed and PushEndpointRefused (deduplicated per instance),
maintenance sweep inside the existing run, settings template.
The tenant-scope provider stays Global-Admin-only until the customer release (same
ValidateModel gate as Telegram). Backend 7371, library 125, MCP 997, web 2061 tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal, .claude/commands, infra (push-relay: plan, D-327 to D-330, markers, VAPID generator)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* bump pointer: internal (push-relay: commit step ticked)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Drop sprintf-js from both lockfiles (Dependabot #114, #117)
GHSA-hp3w-g68c-fv3c (sprintf-js <= 1.1.3, denial of service through unbounded
precision specifiers) has no patched release, so both chains are cut instead.
MCP (#117): sprintf-js arrived via @huggingface/transformers -> onnxruntime-node
-> global-agent 3 -> roarr. global-agent is only loaded by onnxruntime-node's
CUDA install script, which the image never runs (npm ci --ignore-scripts). The
override global-agent ^4.1.3 drops roarr; 4.x keeps the bootstrap export that
script calls, and onnxruntime-node 1.30 depends on the same range.
Rules tooling (#114): ajv-cli 5.0.0 (no release since 2023) pulled js-yaml 3
-> argparse 1 -> sprintf-js. rules/scripts/validate.js now checks the four rule
sets against their schemas with ajv (draft 2020-12) and ajv-formats directly,
and combine-rules.yml runs it in one step instead of four ajv-cli loops. The
lockfile shrinks from 26 to 6 packages; the fast-json-patch override and the
legacy-peer-deps .npmrc (only there for ajv-cli's ts-node peer) are gone.
Verified: validate.js accepts all 149 rule files plus guardrails.json exactly
as ajv-cli did and rejects a corrupted copy (missing field, broken JSON, U+2028
in a guardrail value) with exit 1; clean npm ci + combine.js leave the
generated outputs unchanged; MCP build and 997 tests green; zizmor clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal, .claude/commands (validate.js replaces ajv-cli: B-gzv, rule-builder fallback)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Bump sharp, proxy-addr and source-map-js past fresh advisories (MCP, Web)
- sharp 0.35.4 -> 0.35.5 (GHSA-wq5f-xc86-pv6w, librsvg): the existing sharp
override in both projects now floors at ^0.35.5 so the fixed build cannot be
undercut by a parent range.
- proxy-addr 2.0.7 -> 2.0.8 (GHSA-jqcg-44mw-7w3h, via express; MCP only) and
source-map-js 1.2.1 -> 1.2.2 (GHSA-68fv-2mgg-jv7q, via postcss) are
lockfile-only bumps inside the ranges their parents declare.
No other lockfile entry moved. npm audit in the MCP server is clean.
Verified: MCP build and 997 tests green; Web tsc clean, 2061 tests green,
next build (static export) succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (push-relay: CORS verified, cert-exclusion prerequisite, EU app facts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Web: Next.js 16.3.6 -> 16.3.8 (six advisories)
16.3.8 closes GHSA-3w37-wq28-93x7, GHSA-4jqv-mc3x-m676, GHSA-39w2-rjm5-chcv,
GHSA-f87g-xv8r-7p7x, GHSA-mcj8-r9mp-w47p and GHSA-cjq9-62q9-8jv4 (affected
16.0.0 - 16.3.7). Under output:"export" the cache, ISR and image-optimization
paths never run on the SWA; the dev-server disclosure is local-only. The patch
is taken anyway. next, eslint-config-next and @next/bundle-analyzer stay
aligned and floor at ^16.3.8; the lockfile is pinned to 16.3.8, not the 16.4.0
minor the caret would resolve to.
What npm audit still reports (braces, micromatch, chokidar, fast-glob,
postcss-selector-parser) comes through Tailwind 3 and eslint-config-next,
build-time only; braces has no fix, the rest needs Tailwind 4 (backlog).
Verified: tsc clean, eslint 0 findings, 2061 tests green, next build
(16.3.8, 102 static pages) succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* bump pointer: internal (B-dzs lockfile merge note, B-hxz braces chain)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Backend 1.5.1256 raised IngestTelemetry p50 from 66 to ~100 ms and
RegisterSession from 90 to ~110 ms. Storage metrics per API located it:
server latency of point reads unchanged, end-to-end latency of QueryEntity
alone up from 3.7 to 6.4 ms, every other API flat. The excess times the
point reads per upload is the whole regression, and the request histogram
gained a second mode 46 ms above the first. The only point-read response
that had changed was the Sessions row (~400 bytes of tracker state and
twins), which every upload read twice in full.
- SessionRowProjections: the guard row for the agent routes (Status,
SerialNumber, owner columns) and the counter merge's slice (counters,
anchor, index key, tracker state and twins, the fields non-terminal
batches consume). The guard takes a select and adds its lock columns;
the inventory reader gets a projected overload, the deletion flows keep
their full reads.
- The counter merge returns SessionIngestSnapshot, a narrow type instead
of a SessionSummary built from a projected row. The processor reads the
whole row only on terminal, diagnostics and transition batches or when
the merge returned nothing, and derives the snapshot there. The SignalR
delta and the control signals carry the same fields as before.
- The signal-only upload reads the same slice; registration takes its
lifecycle entry from the guard's row instead of a second read.
- SessionRowProjectionTests: a select-strict harness pins each list. The
merge's write, index mirror and snapshot, the slice mapper, the guard
row's readers and the owner policy come out identical whole or
projected; a dropped column fails there (verified by mutation).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent's ntp_time_check reports offsetSeconds = NTP time - device time,
so a positive value means the device clock is BEHIND. The session card
said "ahead of UTC" for it (a device 10 h behind showed as 10 h ahead),
and ANALYZE-DEV-007 named its two signals the wrong way round.
- SessionInfoCard: the direction comes from deviceClockDirection
(sessionInfoLogic.ts, pinned by a test with both signs).
- ANALYZE-DEV-007 1.1.1: offsetSeconds > 120 is clock_behind, < -120 is
clock_ahead. Conditions, thresholds and text are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Session durations come from device times, and a device clock can be off by
hours for a whole session or change in the middle of one. Every upload
carries the device send time next to the server receive time; their
difference is the device clock error. This change records the session's
start, WhiteGlove Part 2 start and end on the server clock next to the
device values. Nothing reads them yet, so no stored value and no display
changes.
- ServerTime: the rule, defined by tests/fixtures/server-time/cases.json and
run by both the backend (ServerTime.cs) and the web (lib/serverTime.ts).
- Ingest: ServerTime.Tracker, the rule fed one upload at a time, keeps its
state in the primary-only column ServerTimeState and writes
StartedAtServer and ResumedAtServer in the counter merge (no extra read
or write). It follows a session from its first upload only, and only when
that upload carries agent_started; sessions already under way when this
build arrives get their values at completion. On every upload prefix of
every followed case it gives the rule's start and Part 2 start; a retried
upload with agent_started is not counted as a restart.
- Terminal: the counter reconcile, which every terminal path runs, also
computes the rule over the same event scan and writes StartedAtServer,
ResumedAtServer and CompletedAtServer. Fail-soft: a rule failure never
costs the counter reconcile or the ingest write.
- Re-registration carries the four columns from the fresh CAS read through
the Replace (the agent's first upload often races its registration).
- SessionSummary gets the three fields; the index mirrors them, the index
merge drops primary-only columns; manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The backend default of CollectorConfiguration.OobeUpdatePhaseMode is now Active. While Windows
installs its OOBE quality update, the decision engine holds the post-ESP completion window (at most
3 h) and then waits 60 min for the sign-in; a session nobody signs in to ends as awaiting the user
or incomplete instead of failed. Agents still count a missing or unknown value as Shadow. Shared
manifest and generated wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Microsoft switched off the Office 365 connectors in Teams in May 2026. Provider 1,
its MessageCard renderer, the channel editor option and the flat TeamsWebhookUrl /
TeamsNotifyOn* fields with their fallback branch are gone; the value stays retired.
NotificationChannel.IsSupportedProviderType is now the one rule for channel parsing,
channel validation and the flat WebhookProviderType, so a retired or unknown provider
can no longer be stored and come back as a synthesized channel. The channel editor
looks up the placeholder by value instead of by list index. The header-parsing tests
move to NotificationChannel.GetCustomHeaders; manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The time attribution shows the OOBE quality update only once a session ended. While it
runs, the web now derives it from the events it already holds:
- lib/osUpdateLive.ts ports the calculator's update interval for the current window and
labels it from positive evidence that the update finished: running (restarting),
waiting for sign-in from 2 min after its end, or failed/skipped without a restart.
A package waiting for its restart keeps the update running whatever the page reported.
- Session page: a line under the phase timeline, shown after the first complete walk of
the event pages, with a note when the device has been silent for 15 minutes.
- Progress Portal: the headline says that Windows is installing updates and asks the user
to sign in after the update's restart.
- OobeUpdateVocabulary (Shared) is the one vocabulary of the agent, the calculator and the
web (manifest section oobeUpdate); same strings on the wire, no behaviour change.
- tests/fixtures/os-update-live/cases.json pins the calculator and the port
(TimeAttributionOsUpdateLiveParityTests, lib/__tests__/osUpdateLive.test.ts).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An X click counts a dismissal (DismissCount on the person's row); the second ask waits for the
configured pause (FeedbackCooldownDays), the second dismissal ends the asking. A rating ends it at
once. Ignoring the bubble records nothing, as before.
- A rated row is final: a later dismissal or rating (another tab) changes nothing and sends no second
ping. Answers are written conditionally on what was read (ETag), so two tabs cannot overwrite a rating.
- Rows from before the count read as one dismissal when dismissed; the person keeps UPN and name.
- Rules in FeedbackPromptRules; the admin Ratings tab shows "dismissed twice".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- POST feedback/general (MemberRead): text up to 4096 characters, optional reply address, at most
10 per user in 24 hours; one Feedback row per message (PK General), shown in a new admin tab.
- FeedbackReceived ops event without text or sender, plus a bell entry. The help menu gets one
"Send feedback" entry; the dialog links to Submit Logs for Admins and Operators.
- Report uploads: logs are zipped before sending and both forms share one budget derived from the
20 MiB body cap (Constants.SubmissionLimits through the shared manifest). The session exports go
along when they fit next to the attachments.
- Report Session opens to Operators. Comment and contact address are validated; a report whose row
cannot be written fails and its uploaded blobs are removed.
- feedback/status no longer creates a tenant configuration row for an unknown tenant.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first run has no Shell-Core replay (the restart replay re-raises the Hello-wizard start), so an
agent installed late during the ESP missed the update page's start and first half (6a7d241d).
- BackfillUpdatePageTelemetry: on the first run the last 60 min of Shell-Core become oobe_update_page
rows and nothing else — no Hello replay, no ESP exit or failure, no agent_trace, no registry read.
- Live and backfilled names have budgets of 150 each; the first name over a budget sends a
names_capped marker, so a missing name is never read as "did not happen".
- A backfill batch keeps its own last page and moves the live watcher's only with a newer RecordId:
a restart replay's older page start no longer takes over the next live stop, and a first run's
live stop finds the page that started before the agent.
Agent suite 3,436 green; the agent stays on net48.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Field measurement 3: a first-run agent that started after the update page (6a7d241d) left the whole
update in Apps (ESP), failed and skipped updates were reported by nothing, and the lane listed a
failed update's staged servicing stack as an installed KB.
- Time attribution v5: update names before the first observed OobeNDUP start begin the update at the
earlier of the first name and the update's first servicing step (after the window's ESP exit when
one was seen before the name, else the first step of a package the update also serviced later).
The fallback's ESP exit is per window, so a WhiteGlove part-1 exit no longer begins an update in
part 2. OsUpdateSpan carries Outcome (installed/failed/skipped/unknown, the latest evidence wins)
and NotInstalledKbs; Kbs lists only packages that reached Installed. The 2-h sweep recomputes the
last 30 days.
- ANALYZE-DEV-013 (update failed), DEV-014 (not offered after an earlier failure), DEV-015 (update
check ended without a result): warning, never KO; a failure only from explicit names, every
absence condition gated on the agent's names_capped marker; never two at once.
- ShellCoreTracker rows never set the session start (D-315 list), ahead of the agent's first-run
update page backfill.
- The web lane names the outcome and the KBs not installed; MCP get_time_attribution mentions both.
Real-event replay: 6a7d241d os_update 2358 s installed (was 55 min in esp_apps), e7551b82 failed with
KB5124007 not installed. Rule dry runs and a sweep over all 97 update-page sessions since 2026-10-02
hit exactly the five field sessions. Functions 6,918, web 1,701, MCP 997 green.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tailwind 3's divide-<color> rule (`> :not([hidden]) ~ :not([hidden])`,
specificity 0,3,0, plus the dark-mode !important overrides in globals.css)
recolours every child but the first on all sides, so a row's own border
accent only ever showed on the first row.
- Notification dropdown: the purple GA and blue tenant bars are inset
shadows now, so every such row keeps its bar.
- Geographic performance: the red outlier bar moves from the row to its
first cell; it was grey on every outlier but the first table row.
- Tenant config report: the heavier separator between notification
channels and between self-hosted AI clients moves to the cell
(GROUP_SEPARATOR); the divider had shrunk it to a plain 1px line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A concurrent IME writer overwrites lines behind the bookmark, and the overwrite check recovers them a
pass later. Recovered lines were never fresh, so they resolved in the agent's own cached zone: in a lab
the zone was stale (+60, IME wrote +120), recovered result lines landed an hour ahead of the clock and
the platform-script durations were omitted ("n/a" on Install Autopilot Monitor, session d29e27e3, also
on 2.0.1474/2.0.1467). Bytes that read differently at a position were written after that position was
last seen, and IME stamps a line when it writes it, so the last sighting bounds the new line's age.
- Ledger entries carry SeenUtc: set when read or seeded, renewed whenever a verification finds them
unchanged.
- A changed entry below the old bookmark is fresh when the pass is, the ledger held the entry at the
rewind point, and the replaced bytes were seen 0-30 s ago; otherwise the fallback as before.
Re-processed entries are never calibration anchors.
Field replay and longer-running script red before; six mutation proofs; agent suite 3,426 green.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
IME re-runs a failed platform script on later check-ins, and the agent's first pass reads all of
AgentExecutor.log before the IME log, so several runs of one policy shared one slot: the first IME
result took the LAST run's exit code and output (session eff1b413: the bootstrap script "Failed
(exit 0)" with the stdout of its successful retry), every later result went out without an end
block. The exit-code fallback said Success for exit 0 with stderr and Failed for exit 1 without,
which IME never does.
- A later timestamped executor start parks the previous run of its policy (persisted, max 64); an
IME result pairs with the run of its policy and user that started last before the result line;
earlier parked runs of the same user resolve then, so emits keep run order.
- Invocation markers carry the run id; the emitted marker only rises; no dedup path deletes a live
run; a parked emit never takes the later run's D-316 register entry.
- Fallback: Success exactly when the stderr is empty, the exit code only when no stderr line was
read; "write output done … error =" replaces the "error from script" snapshot, empty stdout too.
Backfill replays fail on the old code; nine mutation proofs; agent suite 3,416 green.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Web: the script panel applies the correction to the run its runId names: the saved output replaces the emitted one, a foreign end block's exit code shows as n/a with an explanation, the state follows the corrected data instead of the emitted event type, and the output carries a short "from IME's saved result" note. The timeline marks the original row as corrected later, and the correction event falls under the script-output setting like the other script events. The session page passes the event to the panel; the gather-rule event list knows the type.
MCP: get_session_summary no longer counts a script_failed as an error, ranks it as one or attaches the exit code's catalog text when a foreign correction with IME result Success names its run.
Backend: the correction never heals a stalled session and never counts as a script run.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After reporting a script batch, IME saves every run under HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Policies\<user>\<policy>; ResultDetails carries the stdout IME read from that policy's own result file (the stderr when it failed with error code 99). The agent now checks each emitted platform run against it: a match confirms the output and the exit code of the same end block, a contradiction emits script_output_reconciliation (Warning, outcome "foreign": the saved output replaces the emitted one, the exit code is unverified), and output the log delivered incomplete is completed (Info, outcome "repaired").
A save is paired with the latest run of its user and policy only after the tracker completed a pass that started two seconds after the save was seen, so a stalled poll loop cannot pair it with an older run. Platform completions carry a runId the correction names. Two restart-safe counters (verified, corrected) ride agent_metrics_snapshot and ime_pattern_hits. The new event type is registered in the shared constants, the generated manifests and the MCP event catalog.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two AgentExecutor processes that open the log at the same end write their start blocks over each other. The end block of the one whose start block lost then sat behind the other executor's start block, and position-based ownership handed a Win32 detection script's stdout and exit code to the platform script.
An AgentExecutor.log entry that is not exactly one CMTrace record (a fragment, a merged line or an empty line) is where one writer's bytes end and another's begin: it now closes the platform invocation in front of it and is never matched. Invocation markers live as long as the bytes of their entry: a rewind sets them aside and gives an entry that reads unchanged its markers back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Since agent 2.0.1473 the Windows Update and servicing watchers backfill rows with their event-log
time up to 60 min before the agent started, and the session anchor only skipped the system
timeline watcher, so session starts and durations grew by 22 min at the median.
- Shared: Constants.EventSources.SessionAnchorIneligible and IsSessionAnchorEligible are the one
rule; the agent writes the same constants as Source.
- Backend: the ingest batch (ClassifyEvents, now internal static) and the Events-table probe use it.
- Web: the session duration reads the list from shared-manifests.json.
- Recorded sessions keep their stored start; the rule applies to new ingests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitHub masks only secrets.* by itself. The bearer token Get-StorageAuthHeaders.ps1
mints at run time was never registered as a mask, unlike the Key Vault signing token,
which build-agent, build-msi and publish-scripts mask before exporting it. No caller
prints it today -- every call site captures the hashtable and passes it only as
-Headers -- but this repository's run logs are public, and a single debugging line
would have published it: an uncaptured call of the helper, or Get-Error /
Format-List on a failed request, whose TargetObject is the HttpRequestMessage
including the Authorization header (reproduced locally with a dummy token).
The helper now emits ::add-mask:: for every token it hands out, which covers all six
workflows in one place. Write-Host, never Write-Output: the output stream is the
script's return value. Verified with a mocked az that the result stays one hashtable
and that a nested caller in the Request-BuildNumber.ps1 pattern still returns only
its build number. Only under GITHUB_ACTIONS -- locally the line would print the token
instead of hiding it.
Prompted by an external AI review of the public scripts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The Members card was the only card in the edit modal with a separate
icon column: its title, add form and member list sat 32px in from the
card edge, which on a phone also took 32px from the email field and the
member rows.
- It now uses the layout of its sibling cards: the title on top, the
content at full card width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNLaZbFQTdhQJU9jh37Hn6
Since e537a4b the backend writes a new versioned file per publish, so its
immutable header holds again. The browser revalidates only the manifest
(no-cache) and serves the file it names from its cache (default mode),
instead of revalidating both, the workaround in 762fef4 for the per-day name.
The build log names the baked file ("Platform stats baked into the landing
page from platform-stats.<date>T<time>Z.json (as of ...)"), so every web
deploy shows which publish it carries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
The versioned stats file is served public, max-age=31536000, immutable, and
the manifest platform-stats.json names the current one. Its per-day name
(19e9713a) was unique while maintenance ran once a night; since 82919d47 it
runs every 2 hours and rewrote that file up to 12 times a day, so caches kept
the first copy of the day. The name now carries the publish instant to the
second in invariant culture (platform-stats.2026-10-03T060004Z.json).
Headers and access tier travel with the upload instead of two follow-up
calls, which left the blob briefly without Cache-Control and content type,
and kept it that way (with the manifest still on the old file) when one of
them failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
Crawlers and LLM fetchers run no JS and saw only the skeleton of the stats
band. app/page.tsx now awaits one read of the public stats blob during
next build (lib/platformStats.ts: manifest, then the versioned file it
names) and renders the numbers into the export. StatsBand starts from that
snapshot and replaces it with the live numbers; a failed live read keeps
the baked numbers instead of hiding the band.
The read is awaited at page level, not under Suspense: React moves a
resolved boundary of a page this size into a hidden div at the end of the
body. It uses no-store (route force-static), so Next's fetch cache cannot
hand a later build the previous numbers. Fail-soft: without a snapshot the
page ships the skeleton and loads live as before, and deploy-web.yml warns.
The browser now revalidates both files (no-cache instead of force-cache):
the backend rewrites the per-day file every 2 hours under immutable, so a
copy cached earlier that day would have made the numbers drop after load.
The page body moved to components/landing/LandingPage.tsx, which the
marketing guards render synchronously; StatsBand left the .json() ratchet
baseline; the sitemap lastmod of / includes the body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ddfosgdeCwXo7NvB9xd2
TimeAttributionCalculator v4 cuts two segments out of the phase partition:
os_update from the OobeNDUP page start whose visit holds update evidence
(a servicing step or a name only an update in progress writes) to the last
update evidence before the user is back; awaiting_sign_in from there to the
first evidence of the user, only after a restart. Both replace the phase
span they overlap; without evidence the partition is v3's. OsUpdates names
the KBs and restarts. Reboot gaps no longer start or end on the clock-sync
row logged at boot. The sweep recomputes the last 30 days.
Aggregates carry eight segments; the session lane and Fleet Health show
"Windows Update" (cyan) and "Waiting for sign-in" (sky) with the KBs and
restarts in legend and tooltip; MCP get_time_attribution description;
manifest and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ShellCoreTracker reads CXID, result, name and value from the event properties
(62404..62407 %1/%2), live and in the backfill. The formatted message is
localized: German devices lost result and value, French devices reported no
update page at all. The text patterns stay as the fallback and accept
guillemets. The Hello-wizard start reads the CXID property too.
OobeUpdateTelemetry: page start and stop sit outside the run budget (at most
3 per page), names share a budget of 150 (was 50; the cloud page logs ~45
init names in its first 40 s). ReportPage returns whether it reported.
A RecordId watermark (shellcore-page-watermark.json) claims every update-page
record and persists reported ones, so the restart backfill does not send the
minutes before the restart again. EspAndHelloTracker passes the state
directory.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The form no longer lays out by viewport breakpoint: a wide window can host a
narrow column next to the settings sidebar, where the input shrank to a few
characters. The input now grows from an 18rem basis and wraps below the type
switch when the row has no room; role and Add wrap together.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An open member row keeps role and bootstrap permission as a draft:
Save writes it, Discard or closing the row drops it, so a stray select
change no longer alters anyone's access.
- AddMemberForm takes `stacked` for narrow hosts; the GA tenant editor
modal uses it, so the email field spans the full width there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- EntraAppRoleResolver maps the Viewer app role next to Admin and Operator
(the highest role wins); a claim Viewer gets no bootstrap-token rights.
- The tenant settings page and the GA tenant editor share components/members:
one line per member, a click opens who added it and the actions, Remove
asks in the row, role chips with counts, a search, 25 rows per page. A
vanished chip or a shrunken list moves the selection, so the list never
shows an empty page and never snaps back.
- The add form sits above the list, About Roles is collapsed, and a tenant
with Entra app roles enabled sees that those users are not listed.
- The EntraAppRolesEnabled doc names Viewer; manifest and generated wire
types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CodeQL js/incomplete-hostname-regexp (alert #96) traced DOCS_URL into the
RegExp built in the "Learn more" assertion. The dots were escaped with
[.], which CodeQL does not recognize as a sanitizer. A substring match
on the exact markup needs no escaping and keeps the same checks: a space
after the sentence, the exact href with target/rel, and "Learn more" as
the link text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitBook publishes a page under the slug of its SUMMARY.md group, not its
folder: troubleshooting/ is /troubleshooting-and-support/, trust/ is
/trust-and-security/. A folder URL works only for pages that predate the
group rename (307); for a newer page it is a 404.
- Web: the Progress Portal access hint linked the new troubleshooting
article by folder path (404 live). It now uses DOCS_PATHS.progressPortalOnly.
- MCP: docUrl built folder URLs for every search_docs citation. Live census
over all 58 SUMMARY pages: 47 direct, 10 via redirect, 1 dead.
summaryFolderSlugs now derives the segments from SUMMARY.md; all 60 page
URLs of the real corpus answer 200.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both variants end with a muted "Learn more" to
troubleshooting/progress-portal-only in the customer docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A member without a portal role only ever sees the Progress Portal and never
learned that their organization already runs the full product. Below the
search they now see one muted line: ask your Autopilot Monitor admin for a
role. When the organization holds no session and signed up more than 14 days
ago, the line names the signup date and the support mailbox instead.
- GET progress/tenant-status (AuthenticatedUser, JWT tenant only, no session
data): unused flag plus signup date. Both reads are strict, so a storage
failure is a 500 and never reads as "unused".
- ISessionRepository.HasAnySessionStrictAsync: key-only probe on
SessionsIndex with the primary-table fallback, no catch.
- ProgressTenantStatusResponse + wire parity facts; regenerated manifests and
web/MCP wire types.
- lib/landingTarget.ts: one landing decision for the landing gate and the
activation page. Leaving activation no longer sends Operators, Viewers,
Global Readers and delegated admins to the Progress Portal, and it keeps
the deep link the landing gate saved during activation.
- lib/supportContact.ts: one source for the support address.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Report 3a2207978e4c: a session was failed 30 min after the ESP exit while Windows installed its
OOBE quality update. The update watcher had been blind since July and the engine had no notion
of the phase.
- Windows Update client: 19/20/43/44 come from the System log (provider-filtered XPath), as the
provider manifest routes them; 25/26/31/41 from Operational via their own config list.
Updates are classified os/defender/store/other: only Windows/.NET updates become rows, the
rest is counted into windows_update_activity_summary. Per-channel watermarks persist a record
only after its row and engine signal were handed to the ingress.
- Servicing: CBS steps from the Setup log (windows_update_servicing), client UpdateAgentLCU.
- Engine: OsUpdateActivity and OsUpdateFacts (event-time ordered) anchor an OOBE update phase
after the ESP exit. Each state has its own bound: the update runs at most 3 h (then Failed
oobe_update_not_finished), after it the user has 60 min to sign in (then the engine stops
waiting without a verdict), after the sign-in one window of grace.
- The phase acts only in OobeUpdatePhaseMode=Active. Shadow, the default, reports what it would
decide as oobe_update_phase_shadow right before today's verdict. The agent stamps the mode on
EnrollmentFactsObserved at every start; a later stamp can take Active away, never grant it.
- Agent: a stop without a verdict ends the session like the max-lifetime watchdog
(agent_shutting_down reason=stopped_waiting); the backend classifies it honestly (verdict
origin stopwait), never Failed.
- Measurement: oobe_update_page (the update page and its ExpeditedUpdate_* names; a 62407 value
only as boolean, integer or HRESULT) and oobe_update_state (NDUP and ESP-policy registry
values), both Debug and bounded per run.
- ANALYZE-DEV-004 1.2.0 counts install and download failures only, never scan failures.
- Event types in Constants, MCP catalog and web; shared manifests regenerated; privacy page
names Windows updates installed during the enrollment.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Two code comments of the gather-rule timeline feed named a customer's domain next to the
session id; the session id stays as the regression anchor.
- The get_ime_pattern_health description and the IME Pattern Health admin text no longer send
the reader to "the IME decompile"; the IME-TOKEN-FAILURE and IME-DO-TEL pattern descriptions
say "verified against IME x" / "absent from the x builds" (rules/dist regenerated).
- Code comments that cited decompiled IME builds cite the IME version instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Logparser positions per (rule, file) and the on_change values persist in
State\gather-rule-state.json, bound to the session id. A reboot, hard kill or WhiteGlove
Part 2 no longer replays a log from byte 0 or re-emits an unchanged on_change result. A
replaced log (fingerprint of its first 4 KB) is read from the beginning. Positions are saved
after the events went out; restoring the file is fail-soft. --run-gather-rules stays in memory.
- LogLineReader: a maxLines stop loses no line, an unfinished last line is never split
(interval rules wait one run, one-shot triggers read it again next run), UTF-16/UTF-32 logs
are read by their byte order mark from any position, line ends as StreamReader.ReadLine,
logLineNumber counts from the start of the file, lines over 32 MB are skipped. A maxLines
value that is no positive number falls back to 1000.
- UserProfileResolver no longer caches "no user signed in yet" (detection retried at most
every 30 s); guarded callers expand the token with the profile they checked.
- MCP get_session_events, search_sessions_by_event and query_raw_events accept a gather
rule's own outputEventType; an empty first-page result for an uncatalogued type carries
eventTypeNote with the closest built-in types instead of being rejected up front.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The summary endpoint takes perBand: above 0 it adds bandCves and bandSoftware, each severity and
priority band's top entries outside the lists. The portal asks for 10 per band and shows them only
under a filter, so Attend lists its 7 CVEs instead of nothing; the MCP tool never asks and keeps its
size. The Most exposed controls sit below the title in every state, bands with 0 entries are
disabled, and the first load shows CalculatingDialog (the Geographic Performance dialog without its
page) with separate estimates for all tenants and one tenant. D-308.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The exposure summary groups CVE index rows by product: every new row carries the finding's
CPE vendor:product as Product, and a row without it borrows the product of a row with the
same title. The summary adds topSoftware, distinctSoftware and kevAffectedSessions, and
known-exploited CVEs and products stay listed below the topN cut (capped so the MCP result
stays under its 50k cap). The shared panel is rebuilt: KPI tiles, a severity donut and the
Act/Attend/Track ladder as list filters, and a Most exposed card with Software and CVEs
lenses. get_vulnerability_summary describes the new fields. D-307.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The community box on the gather and analyze rule pages gets a line that an
assistant connected via MCP can draft, validate and test a rule, linking the
AI-assisted rule authoring docs page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host answers /plans, /about, /privacy and the other page paths with a
301 to the slash path first. The navbar, the footer, the landing and
get-started calls to action, the terms page and two portal links to
/settings now point at the slash path directly. A guard renders every
public page with the build's trailing-slash setting and fails on an
internal link without it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New public page /ai, linked from the navbar (AI), the Then just ask section
and the footer. It shows what an AI client can answer from the structured
enrollment data: a question gallery whose answer panel switches between chat
app, VS Code, terminal and self-hosted, the data path (where the model runs,
what reaches us) and how to connect. A test keeps every MCP tool and argument
on the page in line with the server's registrations for tenant users.
The landing, /get-started and /ai send anonymous click and section-view
counts (marketing_cta_clicked, marketing_section_viewed); every link and
button carries a data-track id. Layouts with their own OpenGraph metadata
keep the shared image and the large Twitter card again. The navbar tightens
between lg and xl so the new entry fits at 1024 px. The FAQ names the
documented MCP clients, and the terminal demo uses the real search_sessions
argument (deviceName).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unlisted page /events/<slug>/ for the link in the post-event mail: try it,
request a walkthrough, read the docs, plus the free community editions of
SCEPman and MyWorkID. It is noindex and kept out of the sitemap, robots.txt
and llms.txt. Every call to action sends one anonymous click event
(event_followup_clicked). The customer quote is now shared with the landing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The navbar gets a "Star" pill with the repository's star count from sm up;
phones keep the bare GitHub mark. A new section before the closing CTA
shows one customer quote with a star ask underneath. The footer's
"Star us on GitHub" line is gone, so the page asks twice, not three times.
The count is read once per web deploy (deploy-web.yml, own step, job token
only there) and inlined by next build as NEXT_PUBLIC_GITHUB_STARS, so
visitors' browsers never contact GitHub and the CSP stays as it is.
Without a value the pill renders without a counter (D-303).
The desktop links, bell and sign-in now start at lg instead of md: the bar
already overflowed between 768 and ~1000 px and the pill widened it further.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A stored rule result keeps the rule's template; only the web and the MCP
server resolved it. The notification builders sent the explanation as
stored, so a channel card showed {{reason}}, {{appName}},
{{failedSubcategory}} and {{errorCode}} although the session had recorded
all four values.
- RuleTemplateInterpolator: the backend copy of the resolution, applied in
BuildRuleFiredAlert and AddRuleResultSections before the text is cut.
In a notification a list item whose value was not recorded is dropped
and a token in running text becomes an en dash.
- The cut lands on a line, sentence or word boundary instead of a
character count; the limits are unchanged.
- tests/fixtures/rule-template-interpolation/cases.json: one case file
run by the backend, the web and the MCP server.
- The engine's evidence auto-field list is one constant. The web, the MCP
interpolator and the validate_rule token lint gain timeoutHours, which
the engine already recorded.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat: tenant Hello wait timeout up to 1 hour
The "Hello Wait Timeout" setting (30..300 s in the UI) had no effect in the
V2 agent: its only consumer, HelloTracker.StartHelloWaitTimer, has no
production caller, while the decision engine armed a fixed 300 s
hello_safety deadline after the ESP exit and the tracker ran a fixed 300 s
completion timer after the wizard start.
The setting is now the real Hello budget, clamped to 300..3600 s
(HelloWaitTimeout in AutopilotMonitor.Shared). Every value that was valid
before (<= 300, including the old 30 s default) keeps today's 5 minutes.
- Engine: the agent stamps helloWaitTimeoutSeconds on EnrollmentFactsObserved
(only when > 300), recorded set-once in EnrollmentScenarioObservations.
All six hello_safety arm sites share one builder with the tenant window;
a fact that arrives after hello_safety was armed moves it out. The WDP
backstop is never shorter than the tenant window; the signal census
surfaces hello_wait_extended.
- Agent: the Hello tracker's wizard completion timer uses the same budget;
the stall probe skips session_stalled while hello_safety is pending.
- Backend: ValidateModel rejects values outside 300..3600; stored rows are
read as their effective value, so legacy 30 s rows never block unrelated
saves or backup reverts. Default 30 -> 300; shared manifest and wire types
regenerated.
- Web: input 300..3600, bounds pinned against the C# constants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG
* fix: keep the Hello wait's 30 s minimum and default, raise only the maximum
Product decision: the setting keeps its UI contract (30 s minimum and
default, same description); only the maximum moves to 3600 s. Configured
values up to 300 s keep the built-in 5-minute Hello window, larger values
extend it.
- HelloWaitTimeout: MinSeconds/DefaultSeconds 30; BuiltInSeconds 300 is the
floor of EffectiveSeconds.
- Backend validates only a changed value (30..3600), like the retention cap;
the read-time normalization is gone again.
- The agent stamps the tenant window only above BuiltInSeconds.
- Web keeps the original text and default; only the maximum and its hint
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG
* fix: Hello wait default 300 s, stale-fire guard, deferred stall report
Product decision: default 300 s (what V2 has always waited), minimum 30 s
still accepted, maximum 3600 s. Values up to 300 s keep the built-in
5-minute window, which the settings hint now says.
Review follow-ups:
- Engine: a hello_safety fire whose incarnation was replaced (armed due
later than the fire's due time) is dead-ended, same guard as RealmJoin —
a late tenant window could otherwise be undercut by the queued fire of
the built-in deadline. The tenant fact is recorded only when it extends
the built-in window.
- Stall probe: session_stalled is held back while a tenant-extended Hello
wait (hello_safety or the WDP backstop) is pending and emitted once it is
over and the session is still idle; real activity drops it. Default
tenants never hit the guard.
- Comments: WDP backstop wording, 5-min reference, AgentConfiguration.
- Web pins the default against the shared manifest; tracker constructor
defaults use HelloWaitTimeout.DefaultSeconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG
---------
Co-authored-by: Claude <noreply@anthropic.com>
Every visit by a user without a tenant role joined the tenant broadcast
group before the dashboard redirected to /progress, and the settings
area loaded the bootstrap session list for every role. The backend
refused both by rule: 134 AddToGroup 403s, 19 serial-less session
joins and 27 bootstrap-list 403s in 30 days, each one also an
api_request_failed event.
- lib/signalrGroupAccess mirrors the backend's same-tenant join rules
(broadcast and member-notify groups need an own role or platform
scope, the admin-notify group the Admin or a GA, a session group a
serial for roleless callers, global-admins platform scope, an
unparseable name is a 400). Cross-tenant joins stay with the
backend's platform/delegated admission. The gate keys on
hasOwnTenantOrPlatformRole, not hasTenantReadScope: a delegated admin
without an own role is refused the home tenant's groups too.
- SignalRContext.joinGroup applies it before sending: a refused join
reaches onDenied with the server's status and is never held for the
reconnect rejoin. joinGroup depends on the caller's role fields as
primitives, so its identity changes only when auth/me resolves, the
same commit that delivers the tenant id consumers join with. That
also ends the "tenant-" join the dashboard sent before the tenant id
was known (the 400s).
- TenantConfigContext loads bootstrap sessions only for
canManageBootstrapSessions (mirror of BootstrapManagerOrGA: GA, Admin,
Operator with the bootstrap grant).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under D-267 a managed tenant saw every operator-provisioned access as
"Platform support". A customer in two operator groups run by different teams
got two identical entries under "Who can read this tenant" and could not tell
them apart.
A Global Admin can now give an operator group a customer label (at most 80
characters) with the existing PATCH global/tenant-groups/{groupId}, which takes
only the fields to change: name and/or customerLabel, where an empty label
clears it. The customer sees the label as the entry name; a group without one
and direct grants keep "Platform support". Owned (self-service) groups refuse a
label with 409, because their customers see the managing tenant's name.
RenameTenantGroupAsync becomes UpdateTenantGroupAsync, one read-modify-write on
the meta row.
DelegationSelfService.CustomerNameOf is now the one source of the name a
customer sees for a group. The managers view and the Group detail of the audit
rows written under every member tenant (assign, unassign, group delete) both use
it. Those audit rows carried the internal group name to the customer's audit
page before this change.
Admin, Tenant Groups: operator groups show "Customers see: ..." and get a
"Customer label" button next to Rename. The customer page is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WhiteGlove seal: IME reports "EspPhase: AccountSetup" from the device session
as soon as the device apps are done when the user ESP is enabled. The sealing
classifier counted that as user activity (-40) and left pre-provisioning Part 1
unsealed, so no whiteglove.complete marker was written and the Part-2 boot hit
the 48-hour emergency break. The excluder now also needs the ESP registry to
show AccountSetup progress: the adapter stamps accountSetupProgress on
WhiteGloveShellCoreSuccess, only after the tracker has actually read the
AccountSetup subcategories (unread is never "untouched"). The new excluder is a
subset of the old one, so a verdict can only rise; signals without the key
score as before. WhiteGloveShellCoreSuccess v1 gets its replay fixture.
The Part-1 exit writes whiteglove.complete as its first step instead of after
analyzers, diagnostics upload, grace and drain, where a reseal could kill the
process before it.
Registration failures: a run that exits because register-session failed
records outcome, last error, network link at start and end, the wall-clock
attempt window and the config-fetch outcome in registration-failure.json. The
emergency break sends the record as AgentErrorReport.PriorRegistrationFailure
(null for older agents); the backend bounds it and adds it to the ops details,
the App Insights dimensions and the timeline data. Retry behaviour is unchanged.
The raw registry trigger whiteglove_signal_* is renamed save_result_step_*: the
step exists in every 25H2 enrollment and the old name read as a
pre-provisioning signal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The agent measured the session age at its 48 h break but never sent it.
AgentErrorReport now carries SessionAgeHours (optional, null for older
agents), and the backend puts it into the ops details, the timeline data and
the App Insights dimension. It is information only: the backend cannot derive
it (no row, or a row registered seconds before the break after a hibernate
resume), and it never feeds the severity.
The backend now reads the Sessions row once for the owner binding and the
break verdict. A report for a session without a row (in practice one that
never registered) is Info and writes no timeline event and no EventType index
row: nothing can close or open that session, and the orphan sweep deleted the
event a day later with a Warning of its own. A row under cascade delete gets
nothing written past the lock. Terminal stays Info with lateCleanup; an open
status or a failed read stays Warning with the historical write. Break
materialization now requires GUID ids.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each Sessions row is bound to the device identity that registered it. The
binding ran in shadow; it now refuses every foreign-identity outcome:
register-session answers 403 with session_owner_mismatch before anything is
written, agent telemetry 403 in the error envelope (SessionOwnerMismatch),
the hosted diagnostics upload URL 403. The error channel stays 200 and only
skips its write into the named session. An evaluation defect fails open.
The agent already rotates its session id on the 403 at registration; it now
does the same for the coded 409 (session id claimed by another tenant) instead
of retrying six times and exiting, and fails without retry on any other coded
409 (BackendRefusalException, never an auth failure). A snapshot restored
after a rotation adopts the current session id.
A guard test pins the four call sites and fails when a site observes the
binding without acting on the refusal. Privacy page: session binding under
how data is protected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The audit log, ops events, rule submissions and session reports each
carried a copy of the same paging state. Next bumped the page number and
cursor stack before the request and kept them when it failed, so a failed
request showed "Page 3" over the rows of page 1.
useCursorPager (pure transitions in lib/cursorPager) owns rows, cursors
and the stack; the page number is derived from the stack. A move commits
only after its page loaded, and only the latest move commits. The fetch is
read through useLatest, which retires the per-page workarounds for the
token getter's changing identity.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cross-partition pages (audit log over all tenants, ops events over
all categories) kept one continuation entry per partition that still had
rows. On the tenant fan-out the nextLink outgrew the host's URL limit:
the follow-up request was rejected with HTTP 414 before the function ran,
so Next never loaded a second page for a Global Admin.
The continuation is now the (RowKey, Partition) of the last returned row.
Every page reads each partition through BoundedFanOut (pageSize + 1 rows
after the watermark) and merges by (RowKey, Partition), which is the
display order in both tables. Partitions after the watermark's partition
continue with `RowKey ge`, the others with `RowKey gt`, so equal ops
RowKeys across categories survive a page cut. The merge no longer sorts
by the resolved OccurredUtc while advancing by RowKey, which could skip
rows where the two disagree.
The fan-out was unbounded (Task.WhenAll over every tenant); it now runs
with the shared cross-tenant concurrency ceiling like the session list.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
App install rows now carry their install channel. The ingest folds the RealmJoin
watcher's package started/completed events into AppInstallSummaries rows (channel
"realmjoin", keyed by scope + package id; duration = completion minus the observed
start). IME rows keep their key and have no channel column, so they read as before.
Every per-app aggregate groups on (channel, name): apps list, app analytics and
sessions (new ?source=, default ime), metrics/app, SLA top failing apps and the
version-regression radar (tracker key, episode key, bell link, ops event). The app
install SLA rate includes RealmJoin packages. The ESP-blocking stamp stays IME-only.
RealmJoin packages do not feed the software inventory or CVE correlation.
Web: a RealmJoin pill and an All / Intune / RealmJoin filter on Software > Installs,
the pill on app detail, Fleet Health and SLA, source-aware app links and PDF meta
line, and a hint in the empty Inventory tab. The pill moves out of InstallProgress
into a shared component. MCP: get_app_install_metrics names the source field.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The platform forwards whatever certificate the TLS client presented, so an
unauthenticated caller can hand in a leaf signed by a CA it made up. The
chain engine then fetched the leaf's AIA caIssuers URL before rejecting it:
an attacker-directed GET from the backend, and a worker blocked for the
10 s retrieval timeout. Every legitimate issuer is embedded and Intune
certificates carry no AIA, so downloads are now disabled and the unused
retrieval timeout is gone.
The regression test signs a leaf with a CA that is neither embedded nor
sent and asserts that the listener behind its AIA URL sees no connection;
a self-signed leaf never reaches that path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.
- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
read with ETag, tombstone refused, mutation decides, conditional replace, conditional
insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
Regenerated shared manifest and wire types (web, MCP).
Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).
Bumps internal (D-290, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Offboarding deletes the tenant configuration, and the returning tenant's first login recreated
it with TrialConsumed=false, so the one-time 30-day Pro trial could be chained by offboarding
and re-onboarding.
- The offboard click reads the configuration fail-loud and ORs TrialConsumed into the
OffboardingByTenantPointer (sticky across rounds, same ETag CAS as OffboardCount).
- The first-login write (HandleNewTenantDomainAsync, the one write that sets OnboardedBy)
carries it onto the new row; an unreadable pointer means no write, retried next login.
Re-onboarding itself is unchanged, auto-approve included.
- TenantConfigurationService.UpdateAsync: fresh projected row with ETag, offboarding
tombstone refused (403 TenantSuspended), mutation may decline, conditional replace with
the ordinary pre-write backup, re-read and re-decide on a lost race (409 after 3).
SetTenantPlanTier and StartTrial use it, so a warm instance's cached row is never saved
(it could lift the tombstone during the drain barrier) and two concurrent trial starts
cannot both pass.
- Tests: TenantConfigUpdateTests (service + both endpoints), pointer stickiness and the
offboard endpoint in TenantOffboardFunctionTests, first-login carry-over in
AuthFunctionSideEffectTests; shared EndpointHarness.
Bumps internal (D-288, D-289, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SsrfGuard.ValidateDestinationAsync resolved the webhook host and checked
it, then HttpClient resolved it again for the POST, so a DNS-rebinding
host could pass the check and be connected to an internal address.
The webhook client's SocketsHttpHandler now connects through
SsrfGuard.ConnectToPublicAddress: it resolves once per connection,
refuses the connection if any address is blocked, enforces HTTPS and
connects the socket only to the checked addresses; TLS still runs against
the request host. The pre-flight check is removed, the registration moves
to AddWebhookNotificationHttpClient so a test can prove the wiring, the
notification retry policy no longer retries a refused destination, and
the test endpoint reports the refusal with the same message as before.
IsBlockedAddress follows the same model as the MCP server: all IANA
special-purpose space, IPv6 only 2000::/3, zone ids and IPv4-mapped
addresses refused (IPNetwork.Contains judges a mapped address by its
IPv4 form, so those are refused first).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The SSRF gate resolved the client_id host, checked the answer and then
handed the hostname to fetch(), which resolved it again at connect time.
A DNS-rebinding host could pass the check with a public answer and be
connected to an internal one.
The metadata fetch now runs through a dedicated undici dispatcher whose
connect.lookup is the gate: one resolution per connection, every address
must be public, the socket connects only to those addresses. The global
fetch is untouched.
The address classifier now refuses every IANA special-purpose block
(IPv4), allows only global unicast 2000::/3 minus its special-purpose
blocks (IPv6), and refuses zone ids and IPv4-mapped addresses. The tests
enumerate the unedited IANA registries (tests/fixtures/
iana-special-purpose-addresses) and prove on loopback sockets that a
refused host opens no socket and that an allowed one is connected to
exactly the address the gate returned.
Adds undici ^8.11.2 (engines >=22.19.0).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When ipinfo.io (plus retry) and ifconfig.co both fail, the agent now asks
geo.prod.do.dsp.mp.microsoft.com/geo. It returns only CountryCode and
ExternalIpAddress, but it belongs to the DO/Windows Update endpoint set Autopilot
needs anyway, so it stays reachable where public geo services are blocked.
The session then gets a country-only device_location (source "do-geo", no
timezone, so timezone auto-set stays pending) instead of none.
- GeoLocationService: the three providers share one TryProvider skeleton;
response parsers are internal and unit-tested; GeoLocationResult.Describe()
skips missing parts ("DE" instead of ", , DE") in the event message and log.
- Failure trace carries doGeoError + lastResortProvider.
- Backend: geographic location keys skip empty region/city, so country-only
sessions group as "DE" rather than ", DE"; drilldown still round-trips.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ClientAppNotRegistered refusal (D-279, now enforced) sends Tenant Admins
to this form; its hint required access_as_application, which an app acting
on behalf of users does not need.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each save handler still builds the edited configuration but now sends changedAdminConfigFields(loaded, edited) through lib/adminConfigSave.ts, and nothing at all when nothing changed. This covers the Global Settings, the diagnostics paths, the ops alerts and the vulnerability section. The MCP access policy patches its single field without reading first. A page loaded before another writer (the agent release pipeline, a second operator, a sync job) can no longer revert that writer's fields. Deployed after the backend that serves PATCH global/config.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On 2026-09-25 a Global Settings save from a page loaded before an agent release wrote the whole configuration back and reverted the release's LatestAgentV2* hashes. After that, every 2.0.1465 agent reported IntegrityCheckFailed. The flaw was structural: every writer (portal PUT, plan tiers, three vulnerability-sync stamp paths) replaced the whole row from the copy it held, usually the 5-minute cache.
- IConfigRepository.UpdateAdminConfigurationAsync(mutate, updatedBy) is the one write path. It reads the row fresh with its ETag and applies the mutation to a copy materialized from that read. It computes the changed columns from the converter projections and writes only those onto the freshly read raw entity; a null value drops the column. The replace is conditional, and a conflict triggers a re-read and retry. A no-op writes nothing. Pipeline-owned and unknown columns survive. The full-model SaveAdminConfigurationAsync / SaveConfigurationAsync are removed, and the first row is created with AddEntity.
- PATCH global/config with { fields } (PatchAdminConfigurationRequest) replaces PUT/POST. Its field gate is derived from the persisted columns and rejects server stamps, LatestAgentV2* / LatestBootstrapV2ScriptVersion, PlanTierDefinitionsJson, the sync stamps and redacted placeholders. The route catalog lists PATCH.
- Plan tiers, TriggerMsrcSync, TriggerVulnerabilityDataSync, VulnerabilityDataSyncFunction and VulnerabilityCacheRefreshService.StampAsync each hand in a mutation.
- Tests: AdminConfigurationChangedFieldsTests (stale-page toggle keeps the release hashes, concurrent pipeline write re-read and kept, no-op, rejection, null drop, first row, gate); existing tests adapted; the request guard allows the admin field map.
- Regenerated shared manifest and wire types (web utils + MCP). The web still sends PUT until the web commit that follows the backend deploy.
- The internal pointer is bumped to D-285.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A portal-registered client (amc_) now receives its Entra refresh token only as a compact JWE (RFC 7516, dir + A256GCM, kid amc-rt-v1). The key is derived from OAuthStateSigningKey. At refresh, the registration comes from the envelope, never from the client_id parameter. Before, a copy of the token redeemed without client_id went to the organizations authority and skipped the registration check.
A raw refresh token presented with an amc_ client id is accepted and returned sealed, so these clients migrate on their next refresh. All other clients are unchanged. jose becomes a production dependency; it was a dev-only transitive dependency before. The internal pointer is bumped to D-283/D-284.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The option is a regular tenant-admin toggle in its own "Without pre-registration" block, with the
add-on permission state read from the existing graph-permissions status endpoint. The dashboard
banner and the admin Ready badge key on "any validation method enabled" (feature flag
deviceValidationEnabled, manifest-driven validate* fields) instead of Autopilot alone, so a
device-preparation tenant that validates by device association is no longer told that agent
ingestion is blocked. Stale "shadow mode" wording for Device Association is gone, the
Not-registered report names every method, sessions show "Intune Enrollment", and the privacy
page and FAQ list the new method. Grant script help describes the IntuneDeviceBinding feature
as Intune Enrollment Validation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The certificate's Intune managedDevice id is looked up in the tenant's Intune. With the tenant
switch ValidateIntuneDeviceBinding on, that lookup is the last accepting link of the validator
chain: an enrolled device that is not being retired, wiped or deleted is admitted as
ValidatorType.IntuneEnrollment. A 404 for a certificate younger than 60 minutes is
NotFoundRecentEnrollment (503, never cached); a missing grant is 503 with 120 s.
For devices another validator admitted, the lookup only observes: in the background, never
admitting or rejecting, and only when the app token carries
DeviceManagementManagedDevices.Read.All, so a tenant without the add-on costs no Graph call.
Outcomes ride on the request row (CertDeviceBinding, role, serial match, owner and enrollment
type).
TenantConfiguration.HasAnyDeviceValidation() is the single gate precondition; the feature flags
expose it as deviceValidationEnabled (replaces validateAutopilotDevice).
The agent parses ValidatorType strictly: release the agent before this backend.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The registrations (name, callback URL, client id) live in their own table,
not in TenantConfiguration, so the report only showed the limit. Fetch
GET tenants/{tenantId}/mcp-client-registrations fail-soft alongside the
other side views and render them in the Tenant Status section, plus the
platform switch. The limit row is relabelled to say it is a limit.
Claude-Session: https://claude.ai/code/session_01DbRs7NCppizQ7wWFjN7n3M
Co-authored-by: Claude <noreply@anthropic.com>
The query recognizes only npm limiter packages, never oauthRateLimit /
oauthTokenRateLimit, and flags a handler as soon as it calls a function
named like *auth*, *login* or *verify*. All six alerts it ever raised were
on throttled OAuth routes (the last two came from tenantAuthority()).
What it stood in for is now a test: every route of the OAuth router must
run a per-IP limiter first unless it is one of the three named discovery
documents, /oauth/token must carry the strict budget, and a router.use()
middleware fails the check. Verified by mutation: removing the limiter
from /oauth/authorize fails the test with the route name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The registration lookup put a client_id-derived value into the backend URL
path unencoded. The amc_ regex already limits it to 32 hex characters, so
nothing changes at runtime, but every other request-derived path segment in
the MCP server goes through encodeURIComponent, and CodeQL counts only that
(or a ?/# prefix) as a request-forgery barrier - a regex capture group is
not one. Closes code-scanning alert js/request-forgery on oauth.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A ?serial= parameter (serial number or exact device name) runs one lookup
as soon as sign-in has resolved the tenant, so organisations can send users
a link to follow their own device. Every hit writes the found session's
serial back into the URL for reload, bookmark and copying. Authorization is
unchanged: sign-in stays mandatory and roleless callers still need the exact
serial server-side. The query survives the login redirect because
ProtectedRoute keeps pathname + search. progress_serial_submitted carries
source form | link.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Settings > Tenant > AI Integration (between SLA Targets and Bootstrap
Sessions) explains the normal connection first - server URL for Claude,
ChatGPT, VS Code and other hosted assistants, nothing to register - and
shows the self-hosted client registration below it (Tenant Admin, platform
switch on). The page is visible to admins, platform scopes and anyone with
MCP access. The self-hosted example text is neutral.
- A tenant may hold one self-hosted client registration. The GA-only
TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request:
reverted for non-GA callers on PUT, refused on PATCH, edited in the Global
Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A self-hosted AI client redirects to its own domain, which the vendor
redirect allowlist never admits, so it could not use the browser sign-in.
Tenant Admins can now register the client's exact OAuth callback in the
portal and configure the client with client id amc_<registrationId>.
- MCP OAuth proxy: an amc_ id is resolved through the backend; its
registration is the redirect allowlist entry for that exact callback.
Authorize and token run at the registering tenant's Entra authority, and
the token endpoint discards a token of any other tenant (code and
refresh). Lookups are cached for 60 s, a failed lookup fails closed. Every
other client keeps the allowlist check before its resolution.
- Backend: table McpClientRegistrations, TenantAdminOrGA routes
tenants/{tenantId}/mcp-client-registrations (list, create, delete; at
most three, https or loopback http, exact URI, audited) and the anonymous,
per-IP rate-limited lookup auth/mcp/client-registrations/{id} (under
/api/auth for the client-certificate exclusion list).
- AdminConfiguration.McpClientRegistrationEnabled (default off) is the
platform switch: off hides the portal section and makes every lookup 404.
auth/me carries it for the sidebar.
- Portal: Settings > Tenant > Self-hosted AI clients (Tenant Admin / Global
Admin), and the switch in Admin > Settings > Global.
- Tests: MCP tenant-client flow (authority, tid check, revocation, cache,
allowlist), backend round trip, wire parity, validation, cap, switch,
audit; nav visibility. Manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy issues no client secrets, but its authorization server metadata
named no token_endpoint_auth_methods_supported (RFC 8414 then implies
client_secret_basic) and /oauth/register echoed a requested secret-based
method. A client following that default registered with client_secret_basic
and then failed the token exchange ("client_secret_basic authentication
requires a client_secret"), reproduced with the SDK calls a self-hosted chat
client makes. The metadata now lists ["none"] and registration always answers
none (RFC 7591 lets the server replace a requested value).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A user token obtained by an application other than the platform's own
registrations (for example a self-hosted AI client using on-behalf-of) was
accepted with the user's full role; only audience, issuer, signature and
lifetime were checked. Such tokens are now classified on every request.
- ClientAppBindingMiddleware (after authentication, before policy enforcement)
records the request dimensions ClientAppBinding (registered | unregistered |
unidentified) and ClientAppId, plus a throttled Warning.
- AdminConfiguration.EnforceClientAppBinding (default off; Admin > Settings >
Global): when on, a foreign client is refused with 403 ClientAppNotRegistered
unless the application is an enabled member of the caller's tenant (the
app:<client-id> row a service principal uses). An admitted caller keeps their
identity but gets the application caps: at most Viewer, no platform role,
DelegatedReader on managed tenants, roleless tiers closed except auth/mcp.
- AdminIdentity.IsCapped (application or admitted foreign client) is what the
global, delegated and member-role resolvers switch on; RequestContext carries it.
- Own clients = the audience trust set plus EntraId:TrustedClientAppIds, a
client-only trust for the local development registration that never widens
the accepted audiences.
- Tests: classification, unforgeable cap marker, middleware in both modes, caps
through the real policy middleware, pipeline order; manifests and wire types
regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A backend 401 on `initialize` logged only the UPN, and the backend's own trace
("IDX10214 audience validation failed") never names the token; a registration
rejection logged only a count of redirect_uris. Diagnosing a self-hosted client
(LibreChat) that first failed DCR on its own callback host and then presented
tokens minted for another resource took a customer round trip.
- access-guard: `invalid-token-claims` and `backend-rejected-token` carry
`aud=... azp=... tid=...` (unverified claims; control characters stripped, capped)
- oauth/register: the rejection names origin + path of up to three rejected
redirect_uris (never the query); the error_description names the actual
defaults (VS Code, not Google)
- tests for both lines; skill catalog in .claude/commands (mcp-telemetry) updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Route payloads (__next.*.txt, index.txt), static JSON and the router's HEAD
self-requests were 20k of 62k dependency rows per week and nobody reads them.
lib/webTelemetryExclusions.ts builds the patterns (own origins minus /api/,
plus relative non-API URLs) for two layers: excludeRequestFromAutoTracking-
Patterns for string fetches, and a dependency initializer that drops the
finished item by its absolute target - the SDK's own check sees an empty URL
for the URL objects Next's router passes to fetch (verified against the ingest
batch: 11 such fetches, 0 shipped; API, SignalR and token rows still shipped).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P1 - full loads
- staticwebapp.config.json: /_next/static/* immutable, /landing/* max-age=86400
(SWA default was max-age=30 for hashed assets); guard test pins it
- components/NavLink.tsx: prefetch on hover/focus/touch for the portal chrome
(prefetch={false} also disabled the hover prefetch); landing links stay off
- app/layout.tsx: preconnect to the API origin
- lib/dashboardSeed.ts + lib/prefetchSeeds.ts: the dashboard's first list and
stats requests start with auth/me and are handed to the hooks as one-shot
seeds (the own-tenant list URL carries no tenantId; the backend reads the JWT)
- lib/msalConfig.ts: the login request carries the API scope, so one token
redemption serves the first API call; Graph consent via extraScopesToConsent
P2 - chatter per navigation
- lib/signalrGroupRegistry.ts + contexts/SignalRContext.tsx: group membership
is reference-counted with a 2 s grace leave; reconnect rejoins run in
parallel; on/off/invoke and the context value are memoised; the dashboard's
unconditional leave no longer drops the notification bell's global-admins
membership; consumers pair every join with a cleanup leave
- app/sessions/hooks: both group joins in parallel, the join catch-up is
dropped while the initial walk is in flight, identical session deltas skip
the state update
- lib/cachedAuthFetch.ts: TTL cache for feature flags, tenant list and latest
versions with invalidation on every write path; FeedbackBubble defers its
status call
P3 - pure derivations only (D-274): memoised filter arrays, one-pass phase
summary, status counts, useDeferredValue on both search inputs
P4 - landing
- lib/authHint.ts + AuthGate + globals.css: the loading overlay is applied by
an inline head script only when the site root is opened by a browser with an
auth hint; the prerendered HTML carries no overlay; CSS lifts it after 8 s
- hero as WebP srcset (81 KB instead of a 180 KB PNG), dead landing-v1 CSS
removed, terminal demo pauses off-screen, duplicate twitter image dropped
Telemetry (D-276)
- lib/appInsightsRuntime.ts: the SDK is its own chunk, deferred until idle on
the public host, remote config fetch off (blkCdnCfg), initial trackPageView
on both hosts, Web Vitals as web_vital_* custom metrics
Verified: tsc, eslint --max-warnings 0, vitest 127 files / 1442 tests, next
build, Playwright click-through of every portal route, settings section,
session views and public pages on the production bundle against the live API.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A platform script that ran 10 s was shown as "1h 00m" (session 4377911b): its
start line was self-anchored (+120 min) while its result line, re-read after an
overwrite rewind and therefore never fresh, fell back to the agent's cached zone
(+60 min), was rejected as ahead of the clock, and the duration was taken from
the raw pair. The same shape fired script_timeout_suspected daily in one tenant
on 2-9 s scripts read as 17 h. Fleet 2026-09-20..23: about 1 % of script
durations, 28 of 28 inflated ones carrying derivedTimestamp.
Agent: ScriptExecutionState binds the provenance of its start, result and exit
lines (CmTraceLineProvenance, persisted); EmitScriptCompleted judges the pair -
verified (both measured, or both assumed with the same offset from the same
file), unverified, or suppressed with durationSuppressedReason
(rejected-endpoint | offset-grid, CmTraceOffsetCalibrator.IsOnOffsetGrid) - and
always emits durationProvenance plus startSourceOffset*. A held completion's
sourceOffset* tags now describe its own result line; script_timeout_suspected
only judges a measurable duration; the emitted-run marker is capped at the
clock so a future-shifted result no longer swallows the next run's start.
Web: the reducer applies the same rule to stored events from older agents,
pairing a final with the latest script_started of its policy, and the panel
shows "n/a" with the explanation where the run time would be.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ImeRegistryAppStateObserver reads a fourth IME registry surface,
Win32Apps\OperationalState\<userGuid>\<appGuid>: the install deadline a
downloaded app waits for (ExecutionDeadlineTime, IME 1.106) and the
app-in-use deferral counters (DeferralsUsed, DeferUntilTime,
DeferralMaxDeferrals, DeferralAutoDeferred, IME 1.105). Both travel on the
existing registry_app_state event as data (installDeadlineUtc, deferralsUsed,
deferralMaxDeferrals, deferUntilUtc, deferralAutoDeferred) with the change
triggers installDeadline / deferralsUsed / deferUntil, and the message says
the state in words ("downloaded, install deadline ... UTC", "deferred by user
1/3 until ... UTC", "auto-deferred ..."). No new event type.
ClassifyStatusServiceStatus maps IME's legacy AppInstallStatus explicitly:
1000/1002/1004 installed, 1001/1003 installing, 2000 band notApplicable,
3000 failed, anything else unjudged. The 1000 band was treated as installed,
but IME maps every InProgress enforcement state to 1001, so a running app
counted as terminal success on the registry rail (observability only).
Tests: explicit status mapping, both registry timestamp shapes, the new
change triggers, the wait-state text, and the tick flow for a deadline and a
deferral event. Mutation proof: classifier and trigger broken on purpose
turn exactly the five expected tests red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ajv-cli 5.0.0 is the latest release and still declares fast-json-patch ^2.0.0,
so neither Dependabot nor npm audit fix can move past CVE-2021-4279. An npm
override forces 3.1.1 into the lockfile; the 3.x API is backward compatible and
the validate command used in combine-rules.yml never touches the package.
Verified: npm ci from the lockfile plus all four ajv validate steps pass locally.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The offboard endpoint tombstones the tenant (Disabled=true, "Offboarding in
progress") before it answers 202, so the departing admin submits the farewell
feedback from an already-suspended tenant. Since the suspension gate moved onto
the shared authorization path (43ddc601, 2026-08-29) every feedback POST was
403 TenantSuspended — invisible, because nobody clicked and the two stored
feedbacks predate the gate (D-270).
- Endpoint catalog: `AllowedDuringOffboarding` on exactly
POST tenants/{tenantId}/offboard/feedback; the middleware ignores the
offboarding tombstone for that route only. An operator suspension still
denies; policy tier, cross-tenant check and the function's active-marker
check are untouched. Middleware tests for all four cases plus a catalog
ratchet pinning the flag to the one route.
- Farewell mail: IOffboardFarewellEmailSender.SendAsync returns whether the
provider accepted; the offboarding worker records FarewellEmailSent /
FarewellEmailSkipped / FarewellEmailFailed like the welcome mail — the
send's own success log is Information and never reaches Application
Insights. Missing Email:ApiKey logs Warning. Handler tests per outcome;
OpsAlertRules catalog and the generated manifests (Web + MCP vocabulary)
carry the three types.
- Web: auth/me 403 TenantSuspended no longer shows alert() and forces a
logout. AuthContext keeps the user signed in with claims-only info and
ProtectedRoute renders a suspended page: for the offboarding tombstone
"Your organization is being offboarded" with the farewell feedback form
(a reload during the 6-minute preparation window keeps the form), for any
other suspension the reason text plus contact links; both with sign-out.
The feedback widget moved from OffboardingSection into
components/OffboardingFeedbackForm.tsx.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every tool description and both role instructions now fit the 2048-char host cap
(Claude Code truncated the Global Admin instructions and two descriptions silently).
Pagination, projection and scope mechanics are said once in the instructions and
the shared pageSize/continuation/tenantId/days texts; role markers left tools only
that role can see; get_resource lists its sections on the argument. The SDK $schema
URL is stripped from tools/list.
Measured (compact tools/list): Global Admin 111.4 KB -> 89.1 KB, Reader 94.7 -> 74.3,
tenant user 64.5 -> 51.3, delegated 75.1 -> 55.6 KB. catalog-budget.test.ts pins the
new ceilings, the variant counts and the empty over-cap list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
list_tenants named a real customer domain as its example; the filterTenantList test
carried three real tenants. Both now use synthetic names and GUIDs.
catalog-budget.test.ts pins the per-role wire catalog as a ratchet (size, repeated
argument texts, no foreign domains or GUIDs, the 2048-char host truncation cap with
the four known over-cap texts listed). scripts/audit-catalog.ts dumps and sizes the
catalog per role for before/after measurements.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
zizmor (static analysis for GitHub Actions) now runs twice: locally before a
workflow commit (hard rule in .claude/CLAUDE.md) and as the `zizmor` job next to
the pin gate in action-pins.yml (zizmorcore/zizmor-action pinned by SHA, zizmor
1.30.1 pinned, SARIF into Code scanning, severity low and above). Like the pin
gate it reports and does not block. zizmor.yml holds the single reasoned ignore.
Baseline fixes so the gate starts green:
- persist-credentials: false on every checkout except combine-rules.yml, the one
job that pushes with the checkout credentials (ignored in zizmor.yml, with reason)
- publish-scripts.yml: id-token: write moves from the workflow to the publish job;
indexnow-notify.yml: permissions: {}
- deploy-mcp.yml: env values referenced as shell variables inside bash steps
instead of template expansion
- pester.yml / publish-scripts.yml: reusable workflow via the self-repository form
$/ (GitHub changelog 2026-07-30); the pin gate treats $/ like ./
- combine-rules.yml: ajv-cli installed with npm ci from rules/scripts/package-lock.json
(no ts-node peer, no lifecycle scripts) instead of an ad-hoc global install;
Dependabot watches /rules/scripts
- azure/login pin comment corrected to the tag the SHA actually is (v3.0.2; v3
moved on to v3.1.0), no runtime change
- Dependabot: 7-day cooldown on every ecosystem (security updates are exempt)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The XML summary added on TenantManagerItem.Name in ef280b7b rides into
shared-manifests.json as the field's doc; CI run 35701895623 caught the drift.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET delegations/managers no longer exposes the name of an operator-created
Tenant Group to the customer; both operator shapes (groups and direct grants)
carry the neutral label. Assignees, reader count and source stay visible.
Web badge reads "Provisioned by operators". D-267.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
network_state_change and network_connectivity_check join the non-activity
list next to power_state_change. A flapping dock or access point kept the
idle-stopped periodic collectors running for the whole episode and reset
the stall clock of the enrollment it was stalling. The events are emitted
unchanged; the device-info network snapshots stay activity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IME re-runs remediation policies on its own schedule for as long as the
device is up, so an enrollment that waits for its user reported every run
of every policy. The tracker now emits a policy's first run and every run
whose result differs from the last reported one (compliance, exit code,
stderr, remediation status, error code); repeats are counted and surface
as one script_recurrence_summary per policy when the tracker stops.
The start signal of a known policy is held until its result is known,
because the running indicators pair a start with a later result; a start
without any result is still emitted after a grace. The counters live in
the tracker state file next to the file positions, so a hard kill rolls
both back together. Platform scripts are untouched. The summary does not
count as enrollment activity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stored EventCount doubles on replayed upload batches and was only
corrected at terminal transitions, so the sweep judged running sessions on
an inflated number. It now recounts a candidate before it records the ops
event or blocks the device, skips candidates it cannot verify, and limits
the recount to candidates with a pending action. The warn text no longer
claims an agent loop bug.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
73 sites in 68 files built the query collection with
HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed
both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET
Core request type does not override it, so the change is behaviour-neutral.
- QueryParamGuardTests: ParseQueryString in the Functions project is limited to a
named baseline of three sites that parse something other than the request query.
- A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded
values, caching) so a worker update that changes the implementation fails a test.
- Test mocks that set Url answer Query through CallBase; a loose mock returns null.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six functions wrote their responses through a private wrapper taking the body as
object, which no typed-response guard could see. Their error bodies carried the code
in the error slot, no correlation id, and RestoreRow returned the raw exception text
on a 500.
- Backup job status, row restore and backup trigger answer through ResponseHelper:
errors are the envelope, the 500 is sanitized, the 202 body is BackupTriggerResponse.
- Same for the session-deletion maintenance trigger, RestoreSession's three error
sites and the delegation acknowledgements (MessageResponse).
- Backup codes are constants (Constants.BackupErrorCodes) and part of apiErrorCodes;
RunAlreadyActive and EnqueueFailed join ApiErrorCodes.
- TypedResponseGuardTests gains guard F: no HttpResponseData-returning method with an
object parameter. BackupWireParityTests pins that the move from the backup
serializer options to the worker pipeline leaves the wire unchanged.
- Web reads BackupTriggerResponse from the generated wire types.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A version whose installer was already in the archive could only turn
Archived through the 409 on the write-once upload, which needs a
distribution host that still serves the build. For a version no host
serves any more the row stayed Failed:VersionMismatch next to its own
blob and re-walked every host once a day.
The archiver now checks the archive first: an entry whose ProductVersion
is the observed version ends the job as Archived with the hash and size
of the archived bytes and no download; a missing provenance sidecar is
written without a source URL. An entry holding another build, or an
unreadable one, is not accepted and the walk runs as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The browser loads the photo straight from Microsoft Graph with the User.Read
scope already consented at sign-in; it never reaches the backend. Any failure
(no photo, token, network) falls back to the initials. One request per account
and tab, cached in sessionStorage including the no-photo answer.
The four copied avatar blocks in the navbar become one UserAvatar component.
Graph host added to the URL registry and to connect-src in both CSPs; the
privacy page states where the photo comes from and that it is not stored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A process name is not a protected namespace: any user session can keep a
binary named like the agent resident, which made every agent start during
that session exit 1 before registration and let install mode report a
runtime launch that never happened.
The guard and install mode's launch verification now share one rule
(FindSiblingAgentPid): a name match counts only in session 0 and never for
the caller itself. Every runtime start lands there (Scheduled Task, WMI
launch from the SYSTEM install chain, self-update restart), so behaviour
for real agents is unchanged. The session id comes from the process
snapshot, so no handle is opened; a process that cannot be read does not
count, which fails open into two agents rather than none.
Two interactive --console runs in the same user session no longer block
each other; an interactive run next to the session-0 runtime still does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Banner and the success-rate/duration gauges read the evaluation period
(last 30 days) instead of the current ISO week and name the window in their
labels; the app install gauge says "this week". A target whose period holds
no finished enrollment renders a neutral "No data" card and is left out of
the banner count; with nothing to judge the banner is neutral.
Banner logic moved to lib/slaStatus.ts (pure, tested).
Needs the backend of the previous commit: the page reads
evaluationPeriod / evaluationWindowDays.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SLA breach alert judged the calendar month while the dashboard headline
showed the current ISO week, so an alert could report 78 % while the page it
links to showed 0 everywhere - and an empty week rendered as 0.0 % "Breached".
- SlaEvaluationWindow is the single definition of the SLA populations:
success rate and P95 duration on terminal sessions of the last 30 days
(rolling), app installs on the install attempts of the current ISO week.
The breach evaluator and SlaMetricsService both read it, including the
one-decimal rounding, so the value shown is the value judged.
- SlaMetricsResponse carries EvaluationPeriod and EvaluationWindowDays;
SlaSnapshot carries Period and HasData. A period without a finished
enrollment meets every target flag and is marked HasData = false.
- The dashboard reads from whichever starts earlier, the selected months or
the evaluation window; trend and violators stay on the selected months.
- A lasting breach is notified again only after the cooldown AND once
something finished since the last notification. A new breach episode after
a resolve still notifies. Resolve notifications are unchanged.
- Alert period fact and telemetry tag follow the window constant.
- SlaMetricsService takes a nowProvider; the week-boundary workaround in its
tests is gone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configured diagnostics entry is resolved once to folder, pattern and recursion, and
that target is what gets validated and enumerated; recursion asks the guard before
entering a subfolder, and the user-profile token is expanded before validation.
Security, PowerShell and Sysmon event logs are refused per file, by name and by
resolved channel, in every mode; the portal flags such a path before it is saved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Directly under Dashboard the Progress Portal collected misclicks: 418 of 1,050
admin page views in 90 days went on to the dashboard within 5 seconds, from
every origin page. It is the end-user page; regular users keep it as their only
entry via REGULAR_USER_ITEMS. Annotations showed the same walk-through pattern
and moves down with it. Fleet Health, Geographic Perf. and Software lead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
All five cards showed the same bar-chart glyph on a differently coloured
block, so the colour carried no meaning. Each card now has a small grey
outline icon for its metric (pulse, check, stopwatch, calendar, x) in the
top-right corner, and the label gets the freed width. Failed Today turns
its value and icon red only when the count is above zero and reads
"No failures" at zero.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The analyze and gather rule pages loaded the community submissions list for
every role. rules/submissions is TenantAdminOrGA, so an Operator or Viewer got
a 403 and a red "Failed to load your community submissions" banner on every
page visit (16 denials in 24 h from two operators of one tenant).
The pages now skip the list for callers who cannot read it and the
contribution box says that only tenant admins can contribute and follow the
tenant's submissions. Tenant admins, Global Admins and a global-scope override
are unchanged; the backend policy stays as it is.
Bumps the internal and .claude/commands gitlinks (platform-health marker
catalog, backlog).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A retry after a poisoned offboarding is a new envelope with a new history
row, and nothing ever deleted from a poison queue. The dead letter of the
failed run therefore stayed until its 7-day TTL and kept the poison backlog
alert firing daily for a tenant that was already gone.
TenantOffboardingHandler.HandleAsync now reports a TenantOffboardingOutcome.
On Completed the worker receives tenant-offboarding-poison in batches and
deletes the envelopes of that tenant; other tenants' and malformed messages
reappear untouched. The sweep never throws: a failure there would keep the
completed envelope on the main queue and end with a Completed run in poison.
A re-pickup of a completed envelope sweeps again.
Other poison queues stay terminal on purpose; their failures have no later
success that supersedes the dead letter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The trigger ran the whole maintenance inside the HTTP request (about three
minutes against a 230 s gateway limit): the portal reported a timeout while the
run went on, a second click started a second run, and nothing serialized those
runs against each other or against the 2h timer.
Backend
- MaintenanceRunGate: the timer and the manual run enter through one blob
lease. A held lease writes MaintenanceSkippedLocked and runs nothing;
MaintenanceStarted is written after the lease is held, the lease is released
last.
- POST maintenance/trigger validates the date, probes the lease (409 while a
run is active), enqueues on maintenance-trigger (fail-hard) and answers 202
with the typed TriggerMaintenanceResponse. MaintenanceTriggerQueueWorker
(self-polling) runs it; a failing or skipped run is not retried.
- The run report travels in the details of MaintenanceCompleted.
- BlobLeaseLockStore is the shared base of the lease lock stores;
SessionDeletionMaintenanceLockStore derives from it, behaviour unchanged.
- The probe lets a failed lease release surface as 5xx: a probe lease still
held at worker pickup would turn the queued run into a silent skip.
Web
- useOpsRunStatus: run status from ops events (latest Started newer than the
latest Completed/Failed = active), used by the session-cleanup banner
(rendering unchanged) and the maintenance trigger section.
- Trigger section: queued / already-active messages, status line, polls every
15 s while a run is active, button disabled meanwhile. The 230 s fetch
timeout is gone.
Shared manifest, generated wire types and the MCP ops-event vocabulary
regenerated (two new ops event types, reshaped response).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A Failed offboarding was a dead end: the worker returns early on a Failed
history, the offboard re-click refuses to re-enqueue on a Failed marker, and
the marker cleanup only warns. Recovery meant deleting the marker row in Table
Storage by hand, while the tenant sat in the list as a tombstone counted on the
Waitlist (its whitelist row is wiped early in the cascade).
- GET global/tenants/{tenantId}/offboarding returns the offboarding record
(marker status, failed phase, attempts, history details) as the typed
TenantOffboardingStatusResponse; GA + Reader, never delegated.
- POST global/tenants/{tenantId}/offboarding/retry re-drives a Failed run: the
History row keeps its RowKey and DrainCompletedAt (the worker's resume anchor)
and counts the retry, marker and history go back to Initiated, the ordinary
resume path re-affirms the Disabled-gate, waits out the remaining drain
barrier and enqueues the envelope. Completed / in-flight answer 409, no
marker 404, a missing history row 500 before any write. Global audit row
RETRY/TenantOffboarding. Nothing retries on its own.
- Tenant editor: an "Offboarding" card replaces the Offboard action while the
cascade owns the row (status, started by, attempts, failed phase and error);
Failed offers "Retry offboarding…" behind the type-to-confirm dialog in its
retry variant.
- Tenant list: tombstone rows carry an Offboarding badge (own Status facet)
instead of Suspended, and no longer count as Waitlist.
- Tests: retry decision (reset order, drain anchor, conflicts, not found,
enqueue failure, remaining-barrier delay), status projection, wire parity
facts, filter facet. Shared manifest and generated wire types regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPG5WCmSwfNAv4xkK62n5r
The run answers when it is done (about three minutes); the default fetch
timeout reported "signal timed out" although the run completed, which invites
a second click and a second run. Wait up to the gateway limit of 230 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The landing-page counters stood still since D-198: the two-hourly recompute
was their only writer and merged Max(live figure, stored figure), while
retention prunes the live figure below the stored one - so new enrollments
never showed up again.
- Hot path increments the per-tenant counters only (CAS): enrollments at the
fresh registration, successes at the one status seam every Succeeded writer
passes (transitioned && Succeeded), events per ingest batch. No batch
touches the global row.
- The maintenance run floors the tenant counters at the live figures (one
multi-field write per tenant), sums the tenant rows and adds the growth
since the previous run to the platform row. The previous sums live in a
baseline row written in one transaction with the platform row. A total is
never derived from an absolute figure again: a shrinking source (retention,
offboarding) costs the growth of one interval instead of freezing the
counter. Without a baseline the sum is a lower bound only.
- TotalTenants rolls up the tenant rows with enrollments, UniqueDeviceModels
a persisted set of seen models. TotalUsers stays a high-water-mark (a
persisted user set would outlive the user-activity retention).
- The platform row is merged with its read ETag and never carries
IssuesDetected: the former full-row upsert undid every increment that
landed during the run.
- Reads that feed the baseline are not fail-soft, and a floor that could not
be written skips the rollup of that run - a swallowed zero would be added
as growth a second time.
Shared manifest and generated wire types: doc comment of PlatformStats only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wipe of the tenant's own self-service Tenant Group handed the derived
group id ("msp-{tenantId}") to the tenant-GUID entry points of SafeWipeService.
Their GUID guard threw on every attempt, so each offboarding since the
self-service delegation change ran into max_dequeue and ended Failed after the
tenant tables were already wiped.
- SafeWipeService: WipeByOwnedGroupPartitionAsync / WipeByOwnedGroupRowKeyAsync
take the tenant GUID and derive the group id themselves; matching filters in
OffboardingFilters.
- The public wipe entry points are no longer virtual: they validate and hand
over to protected virtual cores. Test fakes override the cores only, so the
guard runs under every fake. With the old handler code 16 handler tests fail
on the guard, which is how this shipped unnoticed.
- Tests: handler regression on the owned-group anchors, owned-group variants
incl. verify-abort on a foreign group, filter shapes and guards.
Deploy and recovery of the stuck offboarding: B-5g6.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replaces the two Ready/Waitlist toggles with a Filter popover of five
facets (Plan, Paying, Trial, App registration, Status): OR within a
facet, AND across facets, each option carrying the count it would match
under the other facets, active selections as removable chips, and a
plan breakdown (Community / Pro with paying / Trial / MSP) over all
tenants in the statistics line.
The list badge, the filter and the edit modal now derive the effective
edition through one function; a trial-only tenant reads "Pro (Trial)"
in the list instead of "Community". The App facet is bound to the
dual-app parallel window (legacyConfigured), like the badge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The DPA is published in the customer docs and accepted on the Get started
page before the first sign-in (D-251); the onboarding records the accepted
version on the tenant (D-252). The legal pages still described it as
available on request and as a Pro-only signed document.
- privacy: the Roles section says the DPA is published, applies to both
plans, is accepted at sign-up with the version recorded, and that an
individually signed DPA prevails; External Services links the DPA and its
sub-processor list; Changes no longer hinges on "if you hold a signed
DPA"; Contact no longer offers to send one.
- terms: the DPA is no longer listed as a Pro differentiator; Security and
Transparency links the DPA as applying to both plans. Last updated
11 September 2026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backend/Shared:
- McpQuotaService: ResolveHomeAsync reads definitions, entitlements, plan name and purchased slots once per check; Grow adds slots × per-slot value to every window (a lifted window stays unlimited); the decision and McpPlanLimits carry the breakdown; CheckManyAsync, the charge scope and TargetTenantId are gone
- McpQuotaEnforcementMiddleware: one home charge for every caller; IsExempt = Global Admin only (counted, never blocked; the Global Reader stays quota'd); two 429 texts (organization / user)
- TenantEntitlementService.GetPurchasedDelegatedSlots = max(0, effective slot limit − catalog count), 0 without the delegation right; FeatureEntitlementCatalog gains McpSlot*RequestLimit (Pro 300/6000 per account, 900/18000 per organization = 30 % of the Pro windows; Community and conferred Pro 0)
- PlanTierDefinition gains four nullable slot fields; GET/PUT global/config/plan-tiers return the catalog (UsagePlanCatalogDefaults per edition) so a blank field is explained
- McpUsageQuotaNode / McpOrganizationQuotaNode carry purchasedDelegatedSlots and the slot values (absent without purchased slots)
- Removed: the Tenant Group flag ChargeHomeTenantQuota with its scope plumbing (HomeChargedTenantIds, IsDelegatedAggregate, CatalogDecisionResult), Narrow/quotaExcludedTenants on the session envelopes and the X-MCP-Quota-Excluded-Tenants header, targetTenantId on the 429 body, the foreign-home attribution on McpTenantUsage (HomeTenantId, delegated/homeTenantId on the organization report) and the managed tenant's budget on the manager's managed-tenant list; PATCH global/tenant-groups/{id} is a rename
Web: SectionUsagePlans with four slot inputs, catalog placeholders and a built-in-defaults line; SectionMcpUsage renders "base + slots × per-slot" on both cards and drops the delegated badge; SectionTenantGroups without the charge toggle; SectionDelegatedAccess, the accept page and the privacy page state the new model (privacy last updated 2026-09-11).
MCP: get_fleet_overview no longer merges an exclusion list, the 429 renderer names only the caller's organization, the delegated-scope instruction states the home-charge model; wire types regenerated.
Tests: quota service slot-growth suite, entitlement purchased-slot matrix, conferred-Pro self-service writes (403/409), parity facts for the new and the removed fields; the radar analytics fixture is dated off the real clock (it had aged out of the 30-day window).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The self-seed of every fresh backend instance treated the embedded catalog as authoritative and wrote it over a newer GitHub reseed: on 2026-09-11 it re-inserted PS-SCRIPT-EXITCODE three hours after the reseed had deleted it (D-248), so agents ran 83 patterns. Every explicit reseed now stamps its kind in AdminConfiguration (PK RuleCatalog); the three self-seeds do nothing while a github stamp is newer than BackendBuildInfo.BuildUtc, and an empty table is still seeded from the binary. The IME self-seed deletes built-in rows the embedded catalog no longer ships.
ImePatternHealthService (health matrix and the ime_pattern_hits ingest filter) takes the pattern list from the seeded global partition via ImeLogPatternService.GetGlobalCatalogAsync, never from BuiltInImeLogPatterns, so the health page and the drift statistics follow a reseed without a deploy; the response carries catalog { source, stampedAt, patternCount } (manifests regenerated, web line, MCP description).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test-VM session c3ecb568 (2.0.1460): the tracker loop was not scheduled for 26 s at 100 %
VM CPU while the rest of the agent kept running; five results were read before their
executor end blocks — held and completed correctly (D-247) — and nothing in the fleet
telemetry could have shown the pause (ime_backlog_bytes is an instantaneous value).
- ImeLogTracker.RunPollPassAsync hosts the loop body (file reads, the pending-completion
flush, the token-failure window) and times it on a monotonic clock (MonotonicMillisProvider,
Stopwatch in production): PassMaxMs = longest single pass (time INSIDE — reads, ledger
verification, regex), PassGapMaxMs = longest pause between the end of one pass and the
start of the next (the 100 ms delay, SaveState and any scheduling delay). Restart-safe
like the other health counters (TrackerHealthData); ImeTrackerHealth carries both.
- agent_metrics_snapshot: ime_pass_max_ms / ime_pass_gap_max_ms; ime_pattern_hits:
passMaxMs / passGapMaxMs.
- MCP session coverage: sessionTotals.passMaxMs / passGapMaxMs, and from 5 s on a note that
says the lines were read late, not lost — every event keeps its log line's timestamp; the
overwrite note and the pause note join when both apply.
- Tests: ImeLogTrackerPollTimingTests (maxima over three passes with a fake clock, fresh
tracker at zero, restart keeps and never lowers them); MCP coverage tests for the note
threshold. Agent suite 2941 green, solution build 0 warnings, MCP tsc clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test-VM session 24dc69d1 (2.0.1459) had the overwrite geometry the result hold (D-247)
was built for, yet the hold never fired: "Powershell execution is done, exitCode = 0" —
written by IME's health-script worker (AgentCommon ScriptWorker) to both
IntuneManagementExtension.log and HealthScripts.log — was read as PS-SCRIPT-EXITCODE and
attributed to the pending platform script c81b8053 1.2 s before its executor even started
(IME-log marker: the last PS-SCRIPT-GENERATED; HealthScripts.log: the no-marker fallback to
the last platform start). The completion went out at the result line with the foreign exit
code, and the run's own end block, recovered 150 ms later by the rewind, was dropped as
already emitted. Both codes were 0 by luck; a failing health script would have turned a
succeeded platform script into script_failed. The worker's launch line flipped the pending
script's context the same way (338d9434, twice in one run).
- rules: PS-SCRIPT-EXITCODE deleted — the line is health-script-only (3 hits = 3 cycles,
platform scripts never produce it), HS-EXITCODE already carries the value, and a line that
lands inside a parallel platform run's window cannot be attributed by position.
HS-INVOCATION (new): the worker's command line `"…\agentexecutor.exe" -remediationScript`
(identical from IME 1.104.102 to 1.105.152) as agentInvocationArgument of a non-powershell
kind — the launch line it writes next stays with that invocation. Both take effect on the
next reseed for every agent from 2.0.1458 on.
- tracker: a file without markers resolves to the platform run in flight only when a
platform run has ever opened in that file (IntuneManagementExtension.log after a rollover,
AgentExecutor.log); HealthScripts.log and the app logs never qualify. Test seams:
ProcessLogMessageForTest takes the source file, ClearInvocationMarkersForTest.
- tests: ImeLogTrackerPlatformScriptContaminationTests +5 with the session's real lines —
the worker's exit line pre-fills nothing, its launch line flips no context, HealthScripts.log
lines resolve to no platform script, the context line after an IME-log rollover still finds
the run in flight, and no shipped pattern reads the worker's exit line.
- rules/dist regenerated (node rules/scripts/combine.js); analyze-rules.json catches up with
ANALYZE-OFFICE-001 1.1.1 (6657c6b9 was committed without its dist).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
office_install_failed is emitted only for an error code in the C2R INSTALL scenario; a window without on-disk proof ends silently. The old text described a StreamingFinished-based failure and attributed the early "done" of the built-in app type to IME, which never sees Office CSP installs. Text only, version 1.1.1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first agent with the ledger (2.0.1458, session e7f3c910) lost nothing — 16/16 results,
20/20 end blocks, 20 rewinds — yet 3 of 16 completions carried no exit code and one policy
got an agentexecutor_fallback duplicate. The IME result line reaches the tracker before the
AgentExecutor.log end block of the same run: always when the block was overwritten (the check
the result triggers recovers it one pass later), often without any overwrite (the
AgentExecutor.log read of a pass precedes the IME log read by tens of milliseconds while the
exit line and the result line are 8–30 ms apart), and whenever the poll loop stalls (2 s at
100 % VM CPU). Emitting at the result line dropped every recovered end block, and the late
start line reopened the slot, cleared the emitted-marker and the fallback emitted the same
run again 15 s later.
- CompletePlatformScriptFromImeResult (one core for the handler and the test seam) emits at
the result line only when the slot has its exit code; otherwise the result stays on the
slot (ResultObservedAtUtc, ResultPatternId, ResultHeldSinceUtc) and
FlushPendingPlatformScriptResults — the former FlushStalePlatformScriptResults, still the
home of the 15 s exit-code fallback — emits it at the end of the pass in which the exit
code arrived (the block's output lines come with it), at shutdown, or after
PlatformScriptEndBlockGrace (5 s, wall clock).
- The emitted-marker is a Dictionary keyed by policy id with the run's source timestamp
(state file: PlatformScriptResultEmittedAt; the old list is read as MinValue). A start
line strictly older than the emitted result is the late start line of that run: it owns
the lines behind it in its file, opens no slot, emits no script_started. A start line at
or after a held result closes the hold as the next run; a start line that meets a slot
the result opened stamps StartedAtUtc.
- The adapter binds the completion to the result line (ResultObservedAtUtc, patternId =
ResultPatternId) the way the fallback binds to the exit line, so a held emit keeps its
timestamp, pattern and duration.
- Exit-code lines whose owner has no pending slot any more log why they are dropped;
ApplyExitCode is shared by the handler and the test seam.
- Tests: ImeLogTrackerResultHoldTests (11) and ImeLogTrackerAdapterResultHoldTests (1); six
existing cases that encoded the immediate emit without an exit code now flush explicitly.
Agent suite 2933 green, solution build 0 warnings.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent (v2.0.1458) counts the log blocks it re-read after a concurrent IME process had overwritten bytes behind its bookmark. The IME Tracker rows on the session page show the count and bytes as their own row, apart from Skipped work; the MCP coverage block adds both counters to sessionTotals and, when rewinds > 0, an imeTracker.note saying the script results and exit codes in those blocks are complete — no coverage.gaps line, no degraded flag, so an assistant does not read recovered data as a blind spot. Sessions from older agents carry neither key and show neither.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IME's trace listener opens every log once per process with FileMode.Append and FileShare.ReadWrite
and writes at its own stream position afterwards. AgentExecutor.exe (two processes per user-context
script, one per detection script) has such a listener on AgentExecutor.log and on
IntuneManagementExtension.log, so a process that resumes logging after another one appended
overwrites those bytes: the final file holds the last writer, a byte-position tailer has read the
first version. Session 46749560 lost 6 of 16 platform-script results and 4 of 16 executor end
blocks with every tracker health counter at zero — results fell back to the exit code (one Success
where IME said Failed), one finished script stayed Incomplete. The agentexecutor_fallback results
in the fleet are this race, not late IME logging.
The tracker now keeps a fingerprint ledger of every entry it processed from the two multi-writer
files (FNV-1a 64 over the decoded bytes, one CmTraceEntryAssembler shared by the read loop and the
verification so entry boundaries are identical by construction), verifies it on every script end
signal, on a byte-budgeted cadence while an invocation is in flight (256 KB/s, at least 1 s
apart), in a 4 KB guard zone on every growth pass and on a fragment at the bookmark, and rewinds
to the first changed entry; entries whose (offset, hash) still read unchanged are skipped,
everything else is processed as backlog (never fresh, never a calibration anchor). The
policyId-less executor lines are owned by the nearest preceding invocation marker in their file
(PS-AGENT-ARG opens, PS-AGENT-SCRIPT-START upgrades to the platform script, PS-AGENT-INVOCATION and
PS-AGENT-COMPLETED close) instead of a last-started pointer, so an end block a rewind uncovers
reaches its own script. Restart seeds the ledger from the 64 KB behind the persisted bookmark;
a rollover clears it. Four restart-safe counters (ime_overwrite_rewinds,
ime_overwrite_bytes_reprocessed, ime_verify_passes, ime_verified_bytes) ride agent_metrics_snapshot
and ime_pattern_hits — recovered data, not skipped work, no new event type.
Tests: ImeLogTrackerOverwriteRewindTests (15) drive the positional-overwrite write pattern against
both files, including the two field scenarios with the shipped patterns; agent suite 2921 green,
backend known-actions list extended for the two new patterns.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SecurityValidator stamps ValidatedTenantId once the certificate tenant binding
(or the bootstrap token) proves the tenant, and DeviceValidation once the
device-validation chain has run: the admitting ValidatorType name, None for a
tenant without a validator, Transient for the 503, Rejected for the 403 of
stage device. RequestTelemetryMiddleware resolves the row's TenantId in one
place (policy tenant, proven tenant, then the X-Tenant-Id header only on the
routes that validate it) and copies DeviceValidation as a dimension.
Register, config and upload-url rows carried no TenantId before, so
tenant-filtered KQL missed exactly the calls that run the validation, and the
validators' success line is Information-level and never reaches App Insights.
Proving which validator admitted a device is now one query.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Session 2ebcd480: the last platform script started 27 s after enrollment_complete and its
start line was the session's last event; two days later the Script Executions panel still
ticked "Running (175306s — stuck?)" in amber with "1 running" in the header. Same blind
spot as D-237, one panel over.
Web: applyObservationEnd in lib/scriptExecutions.ts marks a Running placeholder on a
terminal session as Incomplete (bounded by session.lastEventAt); the row stops ticking and
shows "Incomplete" in slate with the watched span as a lower bound, the header counts
"N incomplete" apart from running/succeeded/failed, and the card keeps its React key across
the live→terminal flip. No backend change: there is no per-script row, and the session
counters count finals only.
MCP: get_session_summary adds coverage.scripts.stillRunning and a coverage.gaps line for
terminal sessions, folded from script_started without a later result — the same rule as the
web placeholder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sharp < 0.35.4 bundles a libheif with GHSA-g89c-p67h-r497 and
GHSA-2jg2-4ch7-h545 (Dependabot alert #100, high). Both packages already
pinned sharp through an override; the floor moves to the patched release.
MCP pulls it via @huggingface/transformers, Web via next. Lockfile changes
are limited to sharp, its @img/* binaries and one @emnapi/runtime dedupe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- GetMcpOrganizationUsageResponse.daily: charged requests per day inside the range from the same McpTenantUsage rows (BuildOrganizationDaily, MSP reads included); manifest + wire types regenerated (web + MCP copy); fold test
- Web: viewing another tenant no longer fetches the caller's own usage — no UPN, plan badge or own quota; the summary tiles and the daily chart come from the tenant's daily series. The own tenant stays the caller's usage page. Internal gitlink.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reporting layout (ReportingSidebar) now owns the override-only scope, renders the banner above the header and the selector next to the title like Fleet Health, and hands the scope to the sections through ReportingScopeContext (one hook instance). Delegated callers get neither. Fixes the five-line wrap of the section control row on phones; internal gitlink.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Backend: GET global/metrics/mcp-usage/organization?tenantId= (GlobalReadOrAdmin, TenantScoping.None on purpose — the route enumerates accounts, so the delegated path stays out; 400 without a GUID, no aggregate); GetMcpOrganizationUsageResponse carries `quota` (tenant plan + daily/monthly windows summed from the same McpTenantUsage rows); McpQuotaService.ResolveTenantPlanAsync with public TenantPlanLimits; two folds tests
- Web: TenantScopeSelector on /settings/reporting/mcp-usage (override-only, hidden for a delegated scope), scopedApi.mcpOrganizationUsage, Organization-quota card from the organization response (falls back to the caller's own node only on the own tenant), latest-wins fetch guard, wording follows the viewpoint
- Manifest + wire types regenerated (web + MCP copy); internal + skills gitlinks
Deploy order: Backend before Web — under a tenant override the page calls the new route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment-only. The 500 KB figure is query_table's cap; query_raw_events serializes under
MAX_RESULT_SIZE_CHARS.events = 250 KB. Live 2026-09-10: 100 app_install_failed rows = 193 KB.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The band and its tiles cover the observed span (until the agent stopped); the
session page measures the enrollment (first activity event → verdict). The
tiles now carry that figure as a sub-line so both pages read the same two
numbers. enrollmentWindowOf in lib/standby.ts is the shared definition, used
by the session hook as well.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Revises 14b185db after the pre-release review. The fleet measurement (30 days,
all tenants) showed 87 of 821 sessions with a sleep episode had their verdict
inside the episode — 59 of them one tenant's self-deploying devices closed two
minutes before the device-only detection came due and reopened 5–7 days later.
Stamping the firing clock would have turned those outages into session
duration and pushed the tenant's mean duration from minutes to days.
- OnDeadlineFired posts OccurredAtUtc = DueAtUtc again; the due time stays in the
payload and the two stale-fire guards keep reading it (any stamp stays safe).
- Network Timeline: a timeout verdict (helloOutcome timeout, device-only detection
window) is drawn as a hollow marker "Completed (timeout)" with a legend line and a
plain-language tooltip — a wait expired, nothing observed on the device; the
Asleep block is never split at it. finalizing_grace alone is not an indicator.
- Session Info standby note clips to the two WhiteGlove spans when the card shows
the combined WhiteGlove duration; the dedup key is consumed only by a usable
observation (review findings).
- Stale comments describing the firing-clock contract corrected.
Tests: DeadlineFireTimeStampTests (reducer stays correct for any stamp),
EnrollmentOrchestratorTests (due-time stamp + payload), networkTimelineModel
timeout markers (3), standby windows, WhiteGlove windows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Request gates: an Origin other than the server's own is refused with 403 before any token
work (spec MUST); a token the backend rejects is answered 401 + WWW-Authenticate
error="invalid_token" instead of a final 403. The auth.ts header now names the
backend-audience token forwarding as a deliberate deviation (D-239) instead of calling
it spec-conformant.
Secrets: the per-argument echo policy travels in the tool-call context, so toolError's
"Parameters used" block masks update_tenant_config.fields the same way the log line
does. revert_tenant_config requires backupId (a retried revert without one restored the
snapshot the previous revert had just created). /oauth/token relays Entra errors through
an allowlist (error, capped description, error_codes). OAuthStateSigningKey is required
in production (B-uba).
Results: toolResultText enforces the response cap — a page above it is refused with an
overflow answer naming the pageSize that fits (from this page's bytes per row), never
rows or a nextLink; the telemetry logs the size the page would have had (B-zhp).
query_raw_events with DataJson in the effective projection defaults to 50 rows and
refuses more than 100 before the backend runs (30-day measurement: p90 3.3 KB, outliers
21-29 KB per row) (B-s2y). The caller context carries the request's cancellation signal:
apiFetch, the retry wait and the timeout fallback stop when the client goes away, and the
tool_call line logs cancelled instead of an error.
Tests: 9 new files (origin guard both eras over HTTP, 401 + cancellation, apiFetch abort,
arg policy in error text, signing key, token error body, overflow, raw-events pageSize,
telemetry overflow/cancelled); vitest 844 green, tsc 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A HelloSafety deadline due during Modern Standby fired after wake but was stamped
with its due time, so enrollment_complete and CompletedAt landed inside the sleep
window: the Network Timeline drew "Completed" in the middle of the Asleep block
and three views named three different standby durations.
- OnDeadlineFired posts OccurredAtUtc = FiredAtUtc and carries the due time under
SignalPayloadKeys.DeadlineDueAtUtc; the stale-fire guards (RealmJoin re-arm,
SelfDeploying guard C) identify the incarnation via DecisionEngine.DeadlineDueAtUtc,
which falls back to the signal time for pre-key signal logs.
- SignalSerializer renders Date-tokenized payload strings as ISO-8601 round-trip text
instead of the culture-formatted JValue.ToString() (a replayed deadlineDueAtUtc came
back as "4/20/2026 9:55:00 AM").
- Web: the Session Info standby note is clipped to the same window as the duration
next to it (lib/standby.ts) and printed with seconds, matching the attribution chip.
Tests: DeadlineFireTimeStampTests (late HelloSafety fire, guard C, RealmJoin re-arm,
helper fallback), SerializationTests date-like payload round-trip,
EnrollmentOrchestratorTests late fire, lib/__tests__/standby.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An app still installing when the session's observation ended has no terminal
event and never will. The session page ticked Date.now() - startedAt for it
("11h 39m" on a session that finished hours ago), and its AppInstallSummaries
row stayed InProgress forever - ranked behind failures in the app's session
list and counted in totalInstalls with no named bucket.
Web: applyObservationEnd marks such rows Incomplete once the session is
terminal (isTerminalStatus), bounded by session.lastEventAt; the panel shows a
slate Incomplete badge and the watched span as a lower bound, the "active"
pill excludes them, and the download panel carries the parallel flag. Covers
every historic session without a backfill.
Backend: CloseOpenAppInstallsForSessionAsync closes InProgress rows without a
CompletedAt as Incomplete - at the terminal seam, again on the ingest batch
that carries agent_shutting_down for a terminal session (rows opened after
the verdict), and from the 30-day sweep. The sticky-status reconcile keeps
Incomplete against a late progress-only batch and lets a real terminal win.
App metrics and app analytics disclose the bucket (incomplete,
totalIncomplete) outside the failure rate; manifest and wire types
regenerated.
MCP: get_session_summary coverage gains an apps block and a gaps line naming
the apps the agent's last app_tracking_summary still listed as installing or
downloading, terminal sessions only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closed submissions leave through the daily maintenance sweep (RuleSubmissionRetention, one place for rule and test); pending/approved/published never. The submit dialog takes an optional contact email (a UPN is not always a mailbox), stored for the reviewer and never published. The tenant list shows pending rows by default with the rest behind Show all, and the contribute button shows for every writer, also before the first custom rule exists. DELETE global/rule-submissions/{id} (GlobalAdminOnly, MCP delete_rule_submission) removes test/demo rows for operator and tenant alike. Bumps internal and .claude/commands gitlinks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tenant admins submit references to their own gather/analyze rules; the server freezes the rule, records pre-flight findings and fire stats, and stores it under a 12-character id (RuleSubmissions, SessionReports layout). Attribution is the submitter's frozen choice (anonymous by default) and becomes the published author. Review (approve with reserved id / decline with comment) through /admin/reports/rule-submissions or the MCP tools list_rule_submissions, get_rule_submission, review_rule_submission, reseed_rules_from_github; published is derived from the global catalog, never stored. Analyze rules now get the same server-side author stamp as gather rules; ANALYZE-ID-001 carries author Community. Privacy page names the credit; bumps internal and .claude/commands gitlinks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ANALYZE-ESP-004 fired on every ESP terminal failure of a Continue-anyway
profile and rendered the configured FirstSync limit as elapsed time — the
mayHaveContinuedAnyway flag only says the profile has the button. The rule
now requires the agent's own verdict (app_install_failed with
failureType=esp_apps_timeout), names that app via a same-event filter, reads
failedSubcategory from esp_failure_settle_started and words the limit as a
limit. Verified with test_analyze_rule: cb7036a6 fires, 683f1eff (0x80070652
after 19 min) stays silent.
Rule engine: not_exists with a dataField on event_type/event_data
conditions matches when no event of the type carries a non-empty value —
including an absent type — instead of being dead behind the fieldValue null
short-circuit. Schema and MCP authoring guide say so.
Agent: a Shell-Core 62407 failure that lands inside the provisioning
tracker's 30-s settle window (a third of all ESP terminal failures) now
inherits the pending errorCode / failedSubcategory / category /
likelyCulpritApps, so enrollment_failed, Sessions.FailureReason and the
termination-time app classification see the HRESULT the registry had
already named. failureType stays the Shell-Core value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Entra family (D-231) pushed utils/error-codes.json to 123026 bytes against
the 120000-byte client-bundle budget, breaking the Web CI job.
Raising the cap would have been the wrong repair -- it was set three days ago
with the v2 catalog, and a guard that gets widened whenever content grows is not
a guard. The bytes were elsewhere: confidence "high" holds for 562 of 641 rows
and source "msdoc" for about half, ~19 KB of bundle spent repeating two
constants. The sync now omits them and errorCodeMap restores them while building
the map, so every consumer still sees a complete entry and errorCodeTooltip
renders exactly as before. 123026 -> 102399 bytes, cap unchanged, ~17 KB of
headroom left.
Dropping the fields outright was rejected: the tooltip renders both, so they are
provenance a customer sees. The vocabulary test now asserts on the resolved
entry via getErrorCodeEntry rather than the raw file -- a default changed on one
side only would otherwise make tooltips lie about provenance silently.
internal: D-231/D-232, the plan with its review, backlog B-y5d and B-s2y.
.claude/commands: the platform-health marker-catalog corrections from the
2026-09-09 health pass (weekday vs weekend ingest baseline, CAS retried scales
with load, WhatsNewNotificationSent is not proof of a run).
Seven of 44 tool errors in the 7-day telemetry were invented catalog values, and
in every case the tool description already said the right thing while the
parameter description was bare or contradicted it.
update_tenant_config is the clean proof: its description says "Call
get_tenant_config_schema FIRST", but the fields parameter said "Use
get_tenant_config for current values and exact field names" -- and over 30 days
the model called get_tenant_config 12 times and get_tenant_config_schema once.
The text next to the argument wins, and get_tenant_config cannot answer what it
was pointed at: it shows only the fields this tenant has set, with no type,
writability or nullability.
Also: get_session_events.eventType (event_types catalog, matching the pointer
search_sessions_by_event.eventType already carries), query_table (list_tables)
and test_analyze_rule.rule (rule_schemas, validate_rule first).
The catalog held 2 of 607 entries in entra-join, while the 0x801Cxxxx DSREG
family is what an enrollment meets during hybrid join and the ESP account-setup
phase. The Windows Hello PIN error page is the only MS table that documents it;
both of its tables are now parsed, restricted to 0x801c* (607 -> 641 entries).
The block runs after PROSE_ENTRIES so the Autopilot-specific text for 0x801c03ea
keeps precedence over the generic one. The Mitigation column is not carried --
the catalog describes codes, it does not advise. A "SYMBOL: text" cell yields a
symbol (0x801c004d -> DSREG_NO_DEFAULT_ACCOUNT). The run fails below 30 keys,
because parseTables drops non-matching rows silently and a reshaped page would
otherwise look like a clean run.
decode() now strips zero-width and bidi marks: the page carries U+200B in one
hex cell and four description cells, JS \s does not match it, and such a hex
cell fails every ^0x...$ test. Placed in decode() rather than in the new source
so the next parsed page cannot walk into it -- regeneration shows 34 added,
0 changed.
0x801C044F, the zero-hit lookup that started this, is on none of the three MS
pages and deliberately stays unknown.
The What's new panel prints "Latest agent version" under the release
entries, and the session list draws its outdated badges from the same
endpoint — but the value was cached 12h in each Function instance and 1h
in the browser, so the panel announced the previous release for up to 13h
and two instances could disagree.
- LatestVersionsService: TTL 12h -> 5 min; keep the last successful result
and serve it when the blob read fails, so the shorter TTL cannot blank
the version line on a transient hiccup (failure marker 5 min -> 1 min).
- GetLatestVersions: Cache-Control max-age 3600 -> 60.
- No refresh callback from build-agent.yml: the cache is per instance, a
warm-up request would leave every other instance stale.
The origin is a ~200-byte no-cache blob behind the download alias that
Front Door does not cache, so a warm instance now reads it at most 12x/h.
Backend deploy (workflow_dispatch) pending — B-uq7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gather rules and admin-configured diagnostics paths could never reach a
folder below a user profile. The %LOGGED_ON_USER_PROFILE% token lifts the
C:\Users hard block for AppData\Local and AppData\Roaming, but lifting is
not admitting: the path then fell into filePrefixes, which is matched
literally against the already-expanded path, and the profile name is not
known when that list is authored. The exception only ever worked in
unrestricted mode, and no agent test passed a non-null userProfilePath.
D-228 introduces userProfileFilePrefixes, holding entries relative to the
profile root (AppData\Local\RealmJoin). Both guards resolve them against
the detected profile and match segment-bounded; entries that are rooted,
contain "..", or sit outside AppData\Local / AppData\Roaming are dropped at
load, so configuration can only narrow the hard block's exception, never
widen it. The allowlist is anchored to the detected profile, so a rule
cannot reach another user's AppData. The AppData subdirectory list existed
in both guard classes with the GatherRuleGuards copy unused; it is now one
list and DiagnosticsPathGuards delegates. filePrefixes additionally gains
C:\ProgramData\RealmJoin\choco\logs.
D-229 fixes the pre-flight validators, which must only ever reject what the
agent rejects:
- the portal returned "allowed" on the lifted hard block without consulting
the allowlist, so a rule the agent refuses passed the enable gate and then
failed silently on the device with a security_warning;
- the MCP validator offered logparser the diagnosticsPathPrefixes list that
no collector consults (four paths reported allowed that the agent blocks);
- the MCP validator did not expand environment variables at all, reporting
%ProgramData% targets as blocked although the device reads them.
Measured before changing the portal: no rule in any tenant used the token,
so nothing that was working gets disabled.
Verified: solution build 0 warnings, agent 2897 tests, web 1257 + tsc +
eslint 0, MCP 779, guardrails.json validates against its schema, rules/dist
regenerates without drift. The six new agent tests were green against the
unchanged guard first, which is what proves the exception was inert.
Takes effect on devices only after an agent release: guardrails.json is an
EmbeddedResource in the agent assembly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SHA-256 check proves the ZIP is the one version.json names, but that
manifest lives in the same container as the ZIP: it cannot outlive a
compromise of the download host. The expected publisher travels inside this
script, which the loader verified before running it, so the chain from the
file an administrator assigns to the process that starts is publisher-anchored
end to end.
Install-AutopilotMonitor.ps1 v2.4 -> v2.5:
- Get-UnverifiedAgentBinaries checks every AutopilotMonitor.*.exe/.dll in the
extracted payload for status Valid and the same subject pin the loader
carries; the gate runs immediately before --install and throws on any
rejection, exactly like the hash check.
- Third-party assemblies keep their own publisher's signature and are not
checked: System.Management.Automation.dll ships unsigned, so a gate over all
files would refuse the live payload.
- An extracted folder with no binary of ours is a rejection, not a pass.
- The SHA-256 manifest check is unchanged and still runs first: it proves the
bytes, the signature proves the author.
Verified against the live payload 2.0.1454 (all five of our PEs Valid and
glueckkanja AG, gate passes); appending one byte to AutopilotMonitor.Shared.dll
makes it reject. Pester 55/55.
The portal privacy and about pages carried the pre-signing wording and are
corrected in the same change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quiet third line in the panel header ("Latest agent version 2.0.1452",
faint, the number monospaced) reads from the existing config/latest-versions
endpoint. The monospaced string is the same one the session list shows in its
"Agent Version" column, so a reader can tell at a glance whether their fleet
runs the latest agent.
Portal and signed-in only: the endpoint is AuthenticatedUser, and the public
site has no session list to compare against. The gate sits on the mount of the
small component, so the landing page never even issues the request; a missing
version renders nothing.
A tag is a mutable pointer owned by the action's publisher. These jobs are not
ordinary CI: they hold the code-signing Key Vault token (D-223), the federated
storage credentials (D-222), and combine-rules.yml holds a contents:write token
that pushes to main. Whoever could repoint v7 would run code with all of that.
azure/login and Azure/functions-action were already pinned; the actions/* and
github/codeql-action references were not, on the usual argument that GitHub owns
them and shares the runner's trust boundary. That argument does not survive the
fact that they run in the same jobs as the signing identity. All seven are now
pinned by SHA with the version in a trailing comment -- the comment is what keeps
the line reviewable, and Dependabot rewrites both together.
action-pins.yml is the guard: pins nobody enforces rot, and no review reliably
catches one @v4 added to a 200-line workflow. It lives in its own workflow because
ci.yml deliberately ignores changes to the other workflow files, so a job placed
there would stay silent for exactly the commits it must inspect. Like CI it reports
rather than blocks -- main is unprotected; making it binding would mean adding it
as a required status check, which is a branch-protection decision.
Verified both directions: 49 references pass against the repo, and a fixture with
@v7, @main and a bare SHA fails with three annotations while the local reusable
workflow refs are skipped.
Two floating tool versions closed while here. wix went from the range 5.* to 5.0.2
-- it writes the bytes of the MSI customers install, where AzureSignTool only signs
them. ajv was the worse one: unpinned, and npm runs lifecycle scripts at install,
so a hijacked release would have executed with the push token before a single rule
was validated; now exact versions with --ignore-scripts.
Line endings: sed -i rewrote the workflow files as LF. Git normalises on commit, so
the content diff is unaffected and indexnow-notify.yml carries no change at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The portal showed the two-stage bootstrap output ending in ...[truncated]. That marker
comes from the agent, not from the web view: the stdout is cut at capture time, so no
amount of UI space brings the rest back.
Measured over 1,486 script outputs in the fleet (2026-08-25..09-08): median 39, p90 644,
max 1,501 chars, and the 2,048 cap had never been hit -- the maximum was our own
one-stage bootstrap. The loader preamble of ~460 chars pushed exactly that output over
the edge, i.e. this is a consequence of D-224 and it hit the one output an operator
actually reads.
8,192 covers the realistic worst case (download retries plus relax logging, ~2,700) and
stays 4x below the 32K-char Table Storage property limit that bounds the event
downstream. Other scripts are unaffected in practice: p90 is 644 chars.
Reaches the fleet with the next agent release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blobs carry Content-Type: text/plain and no Content-Disposition, so every browser
renders them inline -- the documented "download the bootstrapper script" step turns into
"select this text and save it yourself", with whatever the editor then does to the line
endings and the signature block.
x-ms-blob-content-disposition makes the link a download in every browser, which is a
server-side fact rather than a per-browser hint in the docs. Command-line clients (the
loader, the MSI runner, curl) ignore the header, so nothing on the device path changes.
Metadata only: the script bytes are untouched, so no version bump and the bump guard
still sees the published copies as current.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The full subject of an EV certificate is a line of street address and registration
numbers and pushed the useful part of the log line off the screen. The CN answers the
question the log has to answer. The full subject is still logged in the one case where it
is the finding itself: a valid signature from a publisher that is not us.
Loader 1.1 -> 1.2; a Pester case pins the short form. Suite on PS 5.1: 45/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bootstrap-loader.log is new with the two-stage bootstrap and told the diag tooling
nothing so far. Names which channel a device was assigned: the loader logs there when
assigned directly, and to bootstrap-msi.log when it runs from the MDM-LOB MSI.
Takes effect with the next MCP deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Invoke-BootstrapDownload.ps1 and Start-AutopilotMonitor.ps1 had become the same script:
download the current installer, run it, log, always exit 0. The MSI copy additionally
downloaded and executed WITHOUT verifying what it got, which is exactly the gap the loader
was written to close. The MSI is now packaging only -- it installs and runs the same file
that is assigned directly in Intune, and both delivery channels get publisher verification
from one implementation.
Dropped from the MSI path: its own Deployed-marker check. That guard lives in stage 2 and
runs there anyway, so the only cost is one download on a device that is already deployed.
Kept: the log file name. bootstrap-msi.log is what the diagnostics tooling keys on to tell
the MDM-LOB channel apart from the Intune script channel, so the loader takes -LogFileName
(default bootstrap-loader.log) and the custom action passes bootstrap-msi.log. A Pester
test now pins that contract across the two files -- renaming the parameter on either side
would leave the MSI channel logging under the wrong name.
Package version 1.0.2 -> 1.1.0 so Intune LOB update detection sees the new payload, and a
new component GUID because the key path file changed.
Loader 1.0 -> 1.1. Bootstrap suite on Windows PowerShell 5.1: 44/44.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/ at the repo root is the customer surface: everything under
Bootstrap/ and CustomerSetup/ is published to the download container by
Publish-BootstrapScripts.ps1, Authenticode-signed and version-gated. The
error-code generator is a maintainer tool no workflow runs, so it diluted
that invariant. .github/scripts/ was not the alternative -- it holds only
helpers a workflow step invokes, and unlike guardrails-in-sync and
shared-manifests-in-sync the catalog has no CI drift gate (the generator
needs IME_ENUMS_JSON from operator tooling and live MS Learn fetches).
REPO_ROOT stays ../.. at the new depth, so only the documented path moved:
the generator header and README, the C# and TypeScript comments, the test
summary, and the description header of error-codes.json together with the
generator literal that emits it (the two must stay identical or the next
regeneration diffs). The web sync re-ran clean at 607 entries with no
change to utils/error-codes.json, which drops the header.
MSI carries an Authenticode signature like any PE, so the same identity and the same
ordering rules apply: signing identity logs in first and only for the Key Vault token,
the storage login stays last, and no environment: on the job.
The embedded Invoke-BootstrapDownload.ps1 is signed BEFORE wix build -- wix embeds the
file as it finds it, so signing afterwards would leave the copy inside the MSI unsigned,
and a device under an AllSigned policy needs the runner to carry its own signature.
The MSI is signed after the build and before the hash and the upload, then both files are
verified (Valid, timestamped, optional publisher pin) while nothing is published yet.
The provenance attestation now also skips runs that upload nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Public repo: keep the reasoning that a later change could break -- signing must precede
the release hashes, the job must never carry an environment, the timestamp is not
optional -- and drop the rest. Comments only, no behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ',' operator elsewhere in this file exists because PowerShell unrolls an array
RETURNED from a function; a property assignment does not unroll, so it wrapped the
byte[] in an Object[] of length 1 and the upload failed converting it back. Signing
itself was fine -- run 34253331703 signed all six scripts and uploaded none, so nothing
reached the download alias.
Added the invariant that would have caught it: signed bytes must be longer than the
unsigned form, since a signature only ever adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Start-AutopilotMonitor.ps1 is the new stage 1 -- the file an administrator assigns in
Intune. It carries no enrollment logic: it downloads the published installer, verifies its
Authenticode signature, and runs it in the same process, passing through every remaining
argument. Assigning the installer directly stays supported and unchanged.
The point of the split is that the customer-held artifact stops changing. Today every
bootstrap change forces customers to replace their Intune copy, so the fleet runs a
distribution of script versions we do not control. It is also what makes script signing
worth doing: signing a file that gets swapped by hand each release is a one-off gain,
signing a loader that never changes is permanent.
The loader is deliberately minimal, because it is the one piece that cannot be fixed
remotely -- every guard placed in it would be frozen for years. All guards stay in stage 2.
Verification pins the certificate SUBJECT, never a thumbprint: certificates get renewed and
a deployed loader cannot be, so a thumbprint pin would strand the fleet at the next
renewal. A hash oracle was rejected for a different reason -- it would have to be fetched
from the same host as the script, so it could not survive a compromise of that host, while
the expected publisher travels inside the loader.
Fail-closed on verification, fail-soft on exit: nothing runs without a valid signature from
us (running unverified code as SYSTEM is the risk being removed here), but the loader still
exits 0, because a device without monitoring is acceptable and a failed enrollment step is
not. The accepted cost is that a device which can never fetch or verify stays silent.
Publisher changes:
- every published script is signed (loader, installer, both -Dev renders,
Test-ShouldBootstrapAgent, Grant-AutopilotMonitorAddOn), and signing is the LAST
transformation: it covers exactly the CRLF/UTF-8 bytes Get-PublishBytes produced, so
nothing may normalise or re-encode them afterwards.
- signing is mandatory for a real publish. Only -DryRun may run unsigned, because the PR
gate executes the publisher without any Azure login.
- comparisons against a published copy strip the signature block first. A timestamped
signature differs on every run, so without stripping the version-bump guard and the
drift check would report a change for every unchanged script.
- the bump guard became Assert-VersionBump and now covers the loader as well.
- version.json gains loaderVersion. Additive, no consumer yet: stage 2 is current by
construction, so only stage 1's version says anything about a customer's Intune copy.
- -SignedOutputDir writes the exact uploaded bytes for the CI artifact, in a dry run too --
that is how a signed script gets inspected before it reaches anyone.
publish-scripts.yml logs in with the signing identity first and only to fetch the Key Vault
token (azure/login sets the default az account and the storage login has to be the one that
stays), then publishes and offers the signed scripts as an artifact. build-agent.yml passes
the same parameters on a stable cutover.
Tests: 14 new Pester cases, including that a validly signed script from a FOREIGN publisher
is refused -- that is what an attacker with any code-signing certificate can produce. Full
bootstrap suite on Windows PowerShell 5.1: 43/43.
Not covered: the MSI runner still downloads stage 2 without verifying it, and the customer
docs still describe the direct assignment only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dispatch with upload_blob=false and create_release=false publishes nothing, so the
Sigstore attestation would write a permanent public transparency-log entry for bytes
nobody ever receives. The step now runs only when the run actually ships something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every assembly we author -- AutopilotMonitor.Agent.exe, AutopilotMonitor.SummaryDialog.exe
and the three AutopilotMonitor.*.dll beside them -- is signed with AzureSignTool (pinned
7.0.1) against a certificate in the corporate code-signing Key Vault, SHA-256 file digest
plus an RFC-3161 timestamp. Third-party assemblies keep their own publisher's signature.
The position of the step is load-bearing: signing rewrites the PE file, so it has to run
before the staging copy, the ZIP and every SHA-256 in "Create release artifact". Those
hashes are the bootstrap's integrity oracle (version.json, AdminConfiguration) -- signing
after hashing would publish hashes that no downloaded file can ever match.
The signing identity is a SECOND federated principal in the same tenant and logs in FIRST,
only to fetch a Key Vault token; the storage login of D-222 follows and owns the az context
from there, because azure/login sets the default account and the last login wins. Both
federated credentials carry the subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main,
so this job must never be given a GitHub 'environment:' -- that rewrites the OIDC subject
claim and would break BOTH logins at once.
No unsigned fallback and no dispatch input to skip signing: a missing repository variable,
a Key Vault failure or a non-zero azuresigntool exit code fails the run before anything is
published. azuresigntool is a native tool, so $LASTEXITCODE is checked explicitly --
PowerShell does not throw on a non-zero exit, and a silently unsigned release is exactly
the outcome signing exists to prevent.
Verification runs against the STAGED bytes, not the build output: every AutopilotMonitor.*
PE in the ZIP must be Valid, must carry a timestamp (without one the signature dies with
the certificate) and all of them must share one signing certificate. Setting the optional
repository variable CODE_SIGN_EXPECTED_SUBJECT additionally pins the publisher, so the gate
proves "signed by us" instead of "signed by someone trusted".
The build provenance attestation now covers the ZIP and both EXEs, so an extracted binary
can be verified without the ZIP. Authenticode and the attestation answer different
questions and both stay: who published this binary (offline, what Windows, SmartScreen and
WDAC evaluate) versus which build produced these exact bytes (gh attestation verify).
Not covered here: the customer bootstrap script (its signature block changes the published
bytes and must follow the dev render; see B-57c, the two-stage loader), the bootstrap MSI,
and the local build.ps1 path, which from now on produces unsigned bits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every storage write against autopilotmonitoreu -- build-counter CAS, agent ZIP and MSI,
version manifests, customer bootstrap scripts, the AdminConfiguration hash oracle -- now
rides on the existing federated credential (subject repo:.../ref:refs/heads/main) instead
of a long-lived SAS in repo secrets.
The REST calls themselves are unchanged: '?<sas>' became an Authorization header, so the
ETag-CAS of the counters, Content-MD5 and the no-cache contract survive verbatim. That is
also why this is not a rewrite onto 'az storage blob upload' -- it cannot express If-Match,
and two concurrent builds could then mint the same build number.
.github/scripts/Get-StorageAuthHeaders.ps1 is the single place that knows the resource id,
the az invocation and the service version. x-ms-version is mandatory for Entra-authorized
requests (>= 2017-11-09) and for any Table request without a SAS; 2021-08-06 was verified
live against both the blob and the table endpoint of this account.
azure/login added to build-agent, build-msi, publish-scripts (plus id-token: write) and
deploy-web; deploy-backend and deploy-mcp already had it. Publish-BootstrapScripts.ps1
-DryRun acquires NO token: the PR gate runs it without any Azure login, and that must
stay true.
The AdminConfiguration merge lost its "secret not configured" skip branch -- a failure
there now surfaces instead of silently leaving the hash oracle pointing at an older build.
AZURE_BLOB_AGENT_SAS_TOKEN, AZURE_BLOB_VERSIONS_SAS_TOKEN and AZURE_TABLE_ADMIN_SAS_TOKEN
are retired once the first runs are green. AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN stays: that
account lives in the pre-migration tenant, where this principal has no identity -- it dies
with the mirror, not before.
Requires on the SP: Storage Blob Data Contributor on the containers 'agent' and 'versions',
Storage Table Data Contributor on the table 'AdminConfiguration'. Granted 2026-09-08.
Title and trailing controls shared one non-wrapping flex row with the controls
marked flex-shrink-0. At 440px (iPhone 16 Pro Max) that squeezed the Operational
Events title into an 86px column and pushed the Refresh buttons of Agent Metrics
and the Tenant Config Report past the viewport, scrolling the whole page sideways.
SectionCardHeader now stacks below sm, which covers all 32 cards that use it.
A width sweep over the portal routes at 440px found two more real overflows, fixed
here as well: the audit From/To date pair, and Alert Rules, where the unbreakable
event-type names and a channel-name input without min-w-0 pushed the severity
selects 99px off screen.
Verified: every swept route now measures zero horizontal overflow at 440px, desktop
is unchanged at 1440px. The header test asserted "no trailing group" via the absence
of gap-3, which the wrapper itself now carries; it checks flex-wrap instead and pins
the stacking classes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card composed its line from the backend's explanation ("did not answer within
3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a
starting instance needs. It now writes its own one-liner and ignores check.message,
so the server's "warming" and a client-side probe timeout read identically and the
wording does not depend on a backend deploy. The backend message is shortened but
still carries the budget for direct API callers.
The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps
activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test
pinning that the stated seconds come from cadence x attempts.
Measured over 14 days, GET health/mcp answered in 4..148ms when warm and timed out at 30s
in 50 of 83 calls — the median call took 27,972ms. A cold start is 20-30s of Container Apps
activation (~98% KEDA plus image mount, ~500ms application), so no UI wait covers it and the
generous timeout only bought a long wall in front of the same answer. The browser's own
budget is the same 30s, so the backend's carefully worded warning never reached the user at
all: they saw a raw DOMException.
Backend: the budget drops to 3s, which sits in the widest observed latency gap
(2347ms..3930ms) so jitter cannot flip the verdict. Our own cancellation now reports the new
status "warming" at Information level (with minReplicas=0 a cold probe is the expected path,
and logging it as a warning poisons the ops signal); HttpRequestException is split out of
that catch and stays a real warning, still without the host in the message. The warm-hit
threshold drops to 750ms — ~5x the warm maximum, deliberately generous because every
measurement comes from a near-idle single-user system and warm latency will rise under
concurrency — and moves into McpReachableMessage so a test can pin the boundary. The XML doc
now describes the probe that exists rather than the wake-signal design the measurements
disproved.
"warming" is a new status value rather than an added DTO boolean because the backend deploys
before the web: an unknown value degrades to a grey card with a clean banner, whereas a
boolean would leave old web showing yellow and "Warnings detected" on every cold start.
HealthCheck.Status now documents the five-value vocabulary, which rides into both generated
TypeScript mirrors as JSDoc.
Web: resolveMcpCardState owns display, banner rating and the poll decision as a pure
function (the suite runs on node, so testable logic cannot live in JSX). A warming server
re-probes every 5s for up to 8 attempts and stays out of the overall banner exactly as
"unknown" already does — scaling from zero is expected behaviour, not a degraded platform —
and only becomes a real warning once the re-polls are spent. The card keeps showing
"warming" while the next probe is in flight so it cannot flicker, carries the blue
in-progress look instead of yellow, and was hoisted out of the healthResult guard: a failing
detailed check would otherwise hide a card that is still polling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column showed high/medium/low under the header "Identification", which reads as a method rather than a rating. The wire field is `confidence` and the tooltip already says "identification confidence"; the header and legend now match. The analyzer toggle and its hover border were still emerald from before the section moved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The portal hides Delete for owned (msp-{home}) groups, but DELETE global/tenant-groups/{groupId} accepts any id: a Global Admin deleting one ended Pro (MSP) for every member with no grace anchor, so a member that raised its retention under Pro was clamped to the Community cap at the next sweep. DeleteGroupCoreAsync now snapshots the owner and calls ProConferralService.RecordLossAsync(member, owner, "group-deleted") per member, owner excluded, independent of assignees. Two tests in RevokeEnforcementTests; internal docs list the fourth writer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Act equals the KEV set by construction (CvePriority: KEV => act), so the KEV tile becomes the Act tile and Attend joins it instead of a duplicate count. The breakdown is counted in the backend over every CVE in the window, like the severity breakdown, because the list is top-N. Both panel consumers read the generated CveExposureSummary type; the MCP tool description names the new block. Wire types regenerated for web and MCP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The caller-supplied X-MCP-Tool-Name header went verbatim into the UserUsageLog
row key ({yyyyMMdd}_{tool}:{endpoint}) and from there into the CAS-conflict
warning of TableCasRetry. EndpointNormalizer.ToolNameKey now strips CR/LF and
every other control character or line separator, replaces the characters Table
Storage rejects in keys (/ \ # ?) and caps the length at 64; an empty result
skips the prefix. Real tool names pass through unchanged, so existing usage
rows keep their keys. A header with an illegal key character previously made
every usage increment fail with 400 (fail-soft, counter never advanced).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The manifest committed with d5d1d7bf carried the types section of the previous HEAD, so
the ntpServer doc string still read "null = use agent default"; SharedManifestParityTests
compares byte-for-byte. Generated output only, no wire change. Bumps the internal gitlink
(plan review addendum).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report still carried two hand copies after D-215: a typed map of C#
initializer defaults for the "custom" highlight, and a TypeScript mirror of
GetAgentConfigFunction's resolution rules for the runtime column.
Backend: the derivation moves verbatim into Services/AgentConfigResolver
(Build = pure, ResolveAsync loads tenant + admin config and the two rule
catalogs). GetAgentConfigFunction keeps the kill verdict, the migrate logging
and the device fields on top; ParseAgentMajor, ResolveDiagnosticsUploadEnabled,
MergeDiagnosticsLogPaths and ResolveMigrateTarget move with it (call sites and
tests retargeted; the QueryParams ratchet baseline shrinks by the file). New
GlobalAdminOnly route config/{tenantId}/effective-agent-config returns the same
AgentConfigResponse without a device (optional ?agentVersion=, default the V2
line). AgentConfigResolverTests pin the rules that used to live in the web:
upload 10, Hosted destination without SAS, SAS regardless of mode, unrestricted
only with entitlement + gate + toggle, operator knobs from AdminConfiguration,
analyzer defaults, hash line per major, migrate override and rejection.
Manifest: SharedManifestParityTests emits "defaults" next to "fields" for
TenantConfiguration and AdminConfiguration and an "agentConfig" section with
the AgentConfigResponse / CollectorConfiguration / AnalyzerConfiguration class
defaults (fresh-instance values; nested objects null). TenantConfiguration.
NtpServer gets the initializer "time.windows.com" the table read already
applies, so the manifest default and a stored row agree.
Web: the catalog rows lose value() and default; the runtime column renders
the endpoint's response, the highlight compares against the manifest defaults,
informational rows (reasons, timestamps, identities, secrets) never read as
custom. Previously excluded fields are visible (configVersion, catalog counts,
sealing pattern ids, hash oracle, migrate target); the collector class
defaults sit in a collapsed "Collector Internals" section. Fail-soft: while the
backend is not deployed the runtime column shows a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Health by Device Model" link searched "{Manufacturer} {Model}" as one substring; the server's
free-text predicate tested it per field, so once the failed sessions were outside the loaded first
page the sweep returned nothing.
- lib/searchQueryTokens.ts: tokenizer shared by the timeline and the session search (terms, quotes,
leading minus, key=value / key:value qualifier when the separator precedes any quote)
- app/dashboard/utils/sessionSearchQuery.ts: 15 qualifiers = exactly the fields both sides search,
matcher over session fields + client-derived extra text, suggestion field lookup, link builder
- useDashboardFilters / SessionTable: filter and suggestions use the grammar; fuzzy only for a
single free term; session_searched gains a `qualifiers` count
- Helpers/SessionSearchQuery.cs: the same grammar for q= on /api/search/sessions (+ global), parsed
once per request; MatchesFreeText is a thin wrapper
- utils/session-search-syntax.cases.json: one case file run by vitest and by
SessionFreeTextSearchTests (parity proof)
- FleetModelHealth/FleetSlowModel/FleetFailingModel: manufacturer + model as stored plus label,
grouped on the pair; useFleetHealth uses the generated wire types; "Unknown" is not a link
- shared manifest + wire types regenerated for the three DTOs
Backend suite 6158/6159 (the one failure is SharedManifestParityTests against uncommitted WIP of a
parallel task), web vitest 1177/1177, build 0 warnings.
The progress portal placed the active step purely by the stored phase. After
whiteglove_complete the backend caps that phase at Apps (Device), so the end
user saw "Waiting for you to sign in" above a step list that still showed
"Installing apps (device)" as running at 43 % (customer report).
The seal detection the headline already used is now one helper,
isParkedAfterTechnicianPart (pre-provisioned, whiteglove_complete, no resumedAt
and no whiteglove_resumed), and resolveActiveStepIndex places such a session on
the first user step: every device step done, 57 %, Account setup current. A
stale Complete phase on older sessions can no longer push it past the sign-in;
once resumed the placement is phase-driven again.
Vitest 24/24, tsc and eslint clean. Backlog B-ydz carries the post-deploy check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report typed its own TenantConfig interface and drifted for five months: 31 stored
fields were missing (payingCustomer, the MSP overrides, three validation gates, the SLA
block, four analyzer flags, app-homing and trial bookkeeping) and three runtime values
were wrong (upload interval 30 instead of 10, diagnostics-enabled without the Hosted
destination, unrestricted mode without the entitlement).
tenantConfigReportCatalog.ts is Record<keyof TenantConfiguration> and
Record<keyof AgentConfigResponse | CollectorConfiguration | AnalyzerConfiguration>:
every key is rendered or excluded with a reason, so a new C# property fails tsc once the
wire type is regenerated. Runtime rows carry the GetAgentConfigFunction rule and a source
tag; entitlement values come from feature-flags, operator knobs from global/config.
A vitest pins the same parity against SHARED_MANIFEST.tenantConfiguration.fields and the
resolution rules (upload 10, Hosted destination, flags-driven unrestricted mode, admin
idle timeout, analyzer defaults). Empty strings count as unset for the custom highlight.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Parallel tool calls of one user hit the same (user, day, endpoint) row in the same instant; without backoff the losers stayed synchronised and 3 of 6 increments were lost. Both increments now use the one CAS helper (merge patch, AddEntity on 404, StorageCasConflict marker) with UsageCounterRetries = 8; the tenant counter's attribution columns travel in the merge patch. Tests pin the budget, the patch shape and the create path.
- IME-EXITCODE-CLASS captures the return-code class of the installer exit code ("lpExitCode is defined as <Success|SoftReboot|HardReboot|Retry|Failed>", verified against the IME string literal) as exitCodeClass on AppPackageState; new action captureExitCodeClass, persisted across the agent restart a reboot causes, last value wins across a Retry loop. IME-EXITCODE-MAPPED-FAILED keeps the error path; both match the same line.
- Adapter: exitCodeClass whenever known, exitCode on every terminal state (Installed/Postponed/Skipped/Error) instead of Error only.
- ANALYZE-APP-018 (warning, apps): app_install_completed.exitCodeClass in SoftReboot,HardReboot; +25 HardReboot, +15 observed system_reboot_detected; never fails the session.
- Web: completed-app exit badge amber (failed stays red), "mapped as SoftReboot/HardReboot/Retry" on timeline and Install Progress.
- Tests: pack end to end (phase, current app, class lines, negatives, restart round trip), adapter payloads, linearity samples, backend rule engine + known actions.
- internal gitlink: plan, D-214, backlog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCP: coverage block (window, IME tracker, collectors, upload, diagnostics, gaps) built from the
health events the summary already fetches (session-coverage.ts, data.<key> slices); validate_rule
warns on a required not_exists of an IME-log-derived event without the ime_tracker_degraded
precondition; authoring guide 'absence'; instructions and prompts point at coverage.gaps.
Rules: guardrails.json imeLogDerivedEventTypes (schema + combine). Backend: catalog invariant test —
absence rules carry the coverage gate or a documented exception (ANALYZE-ID-004).
Agent: diagnostics_uploaded/_upload_failed carry packaging counters (truncated, includedFiles,
skippedFiles, skippedByReason, problemsByKind) — counts only, never paths.
Also: shared-manifests.json regenerated (was stale at HEAD; McpAccessPolicy doc string) + codegen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Order at the end of the modal: MCP Access, Tenant Suspension, Offboard Tenant. Rarely used
levers sit together; the frequently edited plan, contact and limit sections come first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TenantConfiguration.McpDisabled + McpDisabledReason (GA-only, same deny-list and PUT guard as Disabled).
Front door: McpUserService denies every caller of a closed home tenant below the platform roles.
Per request: PolicyEnforcementMiddleware denies a closed home or target tenant for X-Client-Source: mcp
(403 McpDisabled) and narrows the delegated fleet bound, never to null. ClientSourceHeader is the one
reader of the header. The MCP access guard surfaces the backend reason verbatim. GA tenant modal,
list badge and config report; wire types regenerated; internal doc, health marker, gitlinks bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A fixed overlay still receives its parent's box-model margins: the tenants page's
space-y-8 wrapper gave the edit dialog's backdrop a 32 px margin-top, leaving the
navbar un-dimmed. All 21 full-screen overlays now portal to document.body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.
What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.
Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The eight backend POST/PUT/PATCH bodies name their request DTO through
jsonBody<T> from client.ts — the one place a body is encoded — so a renamed
or retyped backend field fails tsc instead of surfacing as a 400 at runtime.
The apiFetch guard additionally refuses `body: JSON.stringify(` and a bare
`jsonBody(` with an empty baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).
Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).
35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).
TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The surface comes from the signed appidacr/azpacr claim (0 = public client = portal;
secret/certificate, app-only or missing = integration), never from a header: portal SPA,
MCP server and API share one app registration in production, so the client id cannot tell
them apart and any token holder controls headers. New AdminConfiguration field
PortalUserRateLimitRequestsPerMinute (default 600, Global Settings round-trip, wire types
regenerated); the existing user budget is relabelled MCP & integrations and keeps the tenant
override and the Pro floor; buckets are keyed per surface; ThrottleSurface is a request
dimension next to ClientSource so the classification is auditable (platform-health check 2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A freshly onboarded tenant spent 45 minutes in 503 because a second instance held a
roleless app-only token minted before the consent; InvalidateTenant never reaches other
instances. GraphAuthFailure is the shared step for all five Graph validators: attempt 1
drops the cached token and reports transient so the existing loop re-mints; attempt 2
keeps each validator's semantics (Autopilot/DeviceAssociation transient with Retry-After
120, CorporateIdentifier/CloudPc/IntuneBinding definitive) and logs a stable marker.
SecurityValidator hands the agent the largest Retry-After requested. Platform-health
check 6 and the marker catalog know both markers; the cloudpc concept doc carries the
contract.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BackupJobStatus, RestoreRowPreviewResponse and RestoreRowCommitResponse were the last response payloads the
portal typed by hand: the C# classes existed in Shared but were not marked [WireContract], so the manifest
never carried them. The attribute pulls them and their enums (BackupJobKind/State, BackupOutcome,
RestoreRowDiffKind, RestoreRowCommitOutcome) into shared-manifests.json and both generated wire-type files;
the three backup pages import those and lib/api.ts drops eleven hand-written mirrors. Attribute only: no
runtime behaviour changes, no backend deploy needed.
Still hand-written in lib/api.ts, with reason: BackupManifest/BackupTableEntry (a blob passed through by
GET global/backups/{id}), BackupTriggerResponse (the 202 body of global/backups/trigger is an anonymous
object outside the typed regime) and the RestoreRow request body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five hand-written customs-archive response types in lib/api.ts duplicated CustomsArchiveRunListResponse,
CustomsArchiveEntryListResponse, CustomsArchiveRunSummary, CustomsArchiveEntrySummary and
TenantOffboardingCustomsArchiveEntry from the manifest; the two pages import those. The annotations page
built its next-page URL from API_BASE_URL + nextLink instead of the scoped builder plus extractContinuation.
PaginatedResponse<T> had no importer. types/diagnostics.ts states why it stays hand-written (no wire DTO
for GET diagnostics/paths).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rules pages, apps tabs, audit, delegations, diagnosis, fleet health, geographic, health check, SLA, usage
metrics, the notification and SignalR contexts, the feedback bubble, global search and the shared hooks
now call fetchJson/fetchOk; the activation page's hand-rolled Bearer fetch (no 401 retry, no
telemetry) joins the layer too. The SignalR join/leave paths keep their visible-refusal semantics through
ApiError, the delegation acceptance explains refusals by code, the health check keeps its 403 wording,
and the local mirrors of SlaMetricsResponse and PlatformUsageMetrics give way to the generated types.
No page, component, hook or context calls authenticatedFetch directly any more. What stays is
documented per file in the baseline's PERMANENT map: the auth/me bootstrap, the SWA's own version.json,
the public stats manifest, the delete-cascade classifier's 202 body, and seven TokenExpiredError
branches where the UI must act differently from showing the message (route, rethrow, batch, retry flag).
Ratchet baseline: 39 → 0 raw fetches, 40 → 7 parses, 25 → 8 token checks (from 228 / 221 / 140).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
About 12 MB/day of severityLevel-1 traces without diagnostic value (MSAL verbose, StatusCodeResult,
CORS success, Application started). Application logs were Warning+ already via Function: Warning.
Measured and documented in internal/docs/backend/telemetry-ingest-shaping.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 33 raw authenticatedFetch sites under app/sessions, app/dashboard and app/progress are gone. Hooks
that receive addNotification as a parameter report failures through notifyApiError (same rendering as
notifyError, injected notifier); fail-soft reads that used to check res.ok read through
.catch(nullOnApiError). The delete-cascade classifier accepts the ApiError of a refused DELETE next to
the 202 Response, so the 409/503/404 branches keep their wording; the polling probe reads the status
from either. Session and event reads name their wire types (GetSessionResponse, GetSessionEventsResponse,
SearchSessionsResponse, SessionStatsResponse, the progress and rule-stats responses); the legacy
list-shaped fallbacks on the single-session read are gone. SessionTimeBreakdownDto is the generated type.
Ratchet baseline: 72 → 39 raw fetches, 67 → 40 parses, 36 → 25 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 46 raw authenticatedFetch sites under app/settings are gone: 20 by the transformer, the rest by hand.
The tenant config load keeps its 403 fallback to the feature-flags view through ApiError; the access
check treats a backend refusal as an outcome and only a failed round trip as transient; delegated
access, MCP users and the offboarding flows read their specialised envelopes through ApiError.code
and ApiError.body. Twenty 'Session Expired toast / else inline' catch dialects collapse to the inline
message apiErrorText already renders; local mirrors of McpUserEntry, PlanTierDefinition,
HardwareRejectedResponse and DeviceNotRegisteredResponse give way to the generated types.
Ratchet baseline: 115 → 72 raw fetches, 111 → 67 parses, 59 → 36 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- StorageClientOptions: Table/Queue 8 s per attempt × 3 attempts + capped backoffs = 25.8 s < the
agent's 30 s upload timeout (pinned by test); Blob 60 s × 4. Every production client takes its
options from there. No Polly over the SDK.
- StorageErrors.IsTransient(Exception): OperationCanceled / IO / HttpRequest / Aggregate-of-those
/ RequestFailed status 0; the ingest answers 503 + Retry-After instead of 500.
- The ingest still takes no caller cancellation (a batch is a unit). GetAllSessionsPageAsync
carries the request token down to BoundedFanOut (Global Admin list); SingleFlightCache hands its
factory a 30 s budget token and evicts on timeout (tenant ids, app-summary snapshot, session stats).
- ProcessStallMonitor: 1 s tick drift ≥ 1 s ⇒ ProcessStall event (sampling bypass) + metric with
cause gc / threadpool / unknown from GC.GetTotalPauseDuration and thread-pool counters;
GcPauseMs on request rows ≥ 100 ms. Measures the instance-local 5–10 s pauses seen in production.
- Gitlinks: internal (plan, D-203, concept doc, B-9hf, lesson), .claude/commands (marker rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 113 raw authenticatedFetch sites under app/admin are gone: 73 by the transformer (fetch + ok-check
+ json parse become one typed call), the rest by hand — 404-as-empty lists read through
.catch(nullOn404), the app-homing, offboard, delegated-admin and tenant-group flows read the
specialised envelope through the new ApiError.body, downloads use fetchBlob, action calls fetchOk.
Session-expired branches that only logged or set the same message are gone; apiErrorText renders
every failure with the reference.
Typing the responses surfaced eight local mirrors that had drifted from the wire types (required
fields the backend leaves optional): BlockedDevice, FeedbackEntry, DistressReport, OpsEvent,
SessionReport, IdentityBinding, UnmatchedSoftwareEntry, DailySummary and the whole
SectionPlatformUsage metric set are now aliases of the generated types, with the optional fields
handled where they render. AdminConfiguration keeps its literal-union narrowing through
fromWireAdminConfiguration instead of an unchecked parse.
Ratchet baseline: 228 → 115 raw fetches, 221 → 111 parses, 140 → 59 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
apiFetch takes a mandatory wire type (60 call sites name their generated DTO; the 28 as-casts
and 31 unknown results are gone) and refuses an empty or malformed 2xx body. It retries once on
429 (never the MCP quota) or 503 when Retry-After is at most 10 s — GET only, never with a
caller-supplied signal, opt-in via retry:true otherwise — and counts the retry on the tool-call
context, which withToolTelemetry writes as `retries` on the tool_call line. ApiError.parsed is
the generated ApiErrorResponse plus domain fields and carries retryAfterSeconds; toolError
renders it in the 503, envelope and rate-limit branches. The lean-projection default lives in
one helper (leanFieldSelection) instead of two copies.
apifetch-typed.guard.test.ts refuses a bare apiFetch( or apiFetch<unknown>; apifetch-retry.test.ts
pins the retry, the opt-outs and the body contract. tools-delegated fakes now answer text() with
the body they claim (the client parses text, not json).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first,
Retry-After header second), fetchJson<T> refuses empty and malformed 2xx bodies instead of
handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and
fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice;
apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the
"Session Expired" dialect into one place and NotificationContext.notifyError uses it.
useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch
and onTokenExpired. dedupedFetchJson<T> for the three feature-flag readers.
lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/
.json()/instanceof TokenExpiredError per file against a baseline that may only shrink
(a count above fails as a new site, below as a stale entry); fetchJson without a type
argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent uploads arrive as bursts of batches ~0.3 s apart with seconds of silence in
between, and every batch raises a SignalR signal. The 300 ms trailing debounce on the
session page either refetched once per batch or hid a tight burst until it paused;
session 46096bcd produced 155 full first-page reads for 261 batches in 13 minutes.
lib/burstScheduler: leading edge + trailing debounce + max wait. The first signal after
a quiet period refetches at once, later signals of a burst refetch once after the
burst's last signal, and a burst that never pauses still refetches every max-wait
(spool replay after reconnect/reboot). Fetch semantics are unchanged: every run reads
the full first page, the server stays the single source of truth, no incremental path.
Session page: trailing 300 ms, max wait 1 s. Progress portal (same pattern, two
requests per refresh): trailing 500 ms, max wait 1.5 s. scheduleFetchEvents loses its
delayMs parameter; the scheduler is created lazily on the first signal so refs stay
out of render (react-hooks/refs). Seven fake-timer tests pin the timing rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Helpers/QueryParams replaces fourteen private days/limit parsers, the three
TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites
under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage
or below-minimum means the call site's default, above-maximum clamps. Strict
(TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400
with a message that names the parameter. Integers are invariant-culture, instants are
invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent.
Defaults and ranges stay arguments at every call site.
Settled differences: the Apps windows clamp above 365 instead of falling back to 30,
days=0 or negative yields the default everywhere, the two global geo endpoints and
the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths
on the HTTP layer, a whitespace date means no filter on every route, the admin batch
sizes reject values above their maximum, and the three culture-dependent
DateTime.TryParse sites parse as UTC.
QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline:
headers, raw cells, the agent version major, the se= of a stored SAS URL);
QueryParamsTests carries the cases the removed copies pinned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TelemetryItemKind moves to Shared; TelemetryItem.ToWire() maps onto TelemetryItemDto
and the uploader serialises the DTO, so both ends share one wire class. The spool line
keeps writing Kind by name (StringEnumConverter registered in TelemetryItemSerializer).
IngestTelemetryFunction.PartitionBatch routes by exact kind name and collects every
item it cannot route or parse; a non-empty rejection list answers the batch with 422 +
TelemetryItemsRejectedResponse (poison, rejectedRowKeys, reason) before anything is
persisted, logs a Warning and writes the new TelemetryItemsRejected ops event. The
agent's existing poison protocol drops exactly the named RowKeys and re-uploads the
rest; before this the ingest skipped the items, answered 200 and the agent cleared
its spool.
Contract tests from both sides: TelemetryWireContractTests (agent) freezes the real
batch body under tests/fixtures/telemetry-wire/ and parses the pinned poison body;
TelemetryWireFixtureTests (backend) dispatches every frozen body with zero rejections,
requires an arm per kind and pins the poison body.
Agent-facing responses implement IApiResponse and go through ResponseHelper, request
types carry [WireContract]; plain ingest refusals use the error envelope, the
register-session and upload-url refusals keep their DTO bodies (the agent reads
ErrorCode/Message). Manifests and generated wire types regenerated (also picks up
four types HEAD had left out of the manifest).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PlatformStats(global,current) took a GET plus a full-row upsert without ETag
from every ingest batch: increments were lost under concurrency and a writer
clobbered fields another writer had just changed. RuleStats put every tenant's
daily rule traffic into one date partition (1,459 rows on 2026-09-04), the
writer issued four serial calls per evaluated rule without ETag, and the
reader was a cross-partition scan whose 10k cap dropped the newest dates.
- D-198: TotalEnrollments / SuccessfulEnrollments / TotalEventsProcessed have
no hot-path writer; the two-hourly recompute is the only source. Only
IssuesDetected still increments, through TableCasRetry (ETag + field-only
merge, AddEntity for a missing row, conflicts as StorageCasConflict). The
tenant counters share the helper.
- D-199: RuleStats PartitionKey "{scope}_{date}", RowKey ruleId.
RecordRuleStatsAsync merges a session's increments per rule, reads the
partition once and writes one transaction (UpdateReplace with ETag / Add),
retrying on 412/409. Readers are PartitionKey ranges per scope; aggregation
and the regression radar fan out over the registered tenants; the cleanup
deletes per scope. RuleStats joins the offboarding composite wipe.
- Legacy rows (PartitionKey = date) are read by an extra query and cleaned by
a date range that checks every row for a bare-date key, until 2026-12-05.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- DiagnosticsBlobStreamer disposes the download result on every exit path
(it was disposed only on the 413 branch), measures open and transfer
separately and fires DiagnosticsDownloadProxied after the copy with
Completed=true/false.
- UserDelegationKeyCache: one user delegation key per process (24 h, re-mint
under a lock with 2 h margin or when a SAS would outlive it) instead of one
storage round-trip per download or upload SAS.
- KevDataService is resolved as one singleton over the typed-client handler
pipeline; the cache warmer used to hydrate a throwaway transient instance
while the correlation service held a cold one.
- Admin and tenant configuration services single-flight concurrent cache
misses (Lazy<Task> per key); the MemoryCache and the in-place projection
semantics stay untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dependency filter dropped every successful storage call and the expected
412/409 outcomes, so storage-side latency and ETag contention were invisible
until a call failed outright (seven days of App Insights held zero storage
dependency rows).
- StorageDependencyFilterProcessor keeps a successful storage call at or above
2 s and stamps SlowStorage=true; the operation name is on the row, the table
name is not (the Azure SDK span carries neither URL nor table).
- StorageMetrics wraps TelemetryClient.GetMetric("StorageCasConflict",
operation, table, outcome): pre-aggregated per minute, bypasses the worker's
adaptive sampling, null-tolerant for tests.
- The CAS loops report conflicts: session event count, session counter
reconcile, session store, tenant stats, MCP user/tenant usage.
- TableStorageService keeps its two-argument constructors (Moq matches by
exact arity); DI resolves the three-argument overload.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every entity-group transaction was chunked by count only; 100 payload-heavy rows
(Signals with chunked PayloadJson, Events with 30k-char Message + DataJson) could
exceed the 4 MiB transaction cap, and the failure surfaced as an opaque 500. The
agent halves its batch only on 413 and treats 5xx as "replay later", so such a
batch wedged the device forever.
- TableTransactionBatcher: 100 actions, 3.5 MB per transaction, 1 MB per entity,
never across partitions; an entity that cannot fit throws
TableEntityTooLargeException. Used by the event, signal, transition, restore
and rules writers; delete-only batches keep their count chunking.
- StorageErrors: one classifier for RequestFailedException (transient 408/429/
5xx, payload-too-large by status or error code, already-exists incl. the
Azurite 400 shape); the two IsAlreadyExistsStatus copies use it.
- Event writer: per-row fallback only for data-shaped rejections; throttling,
outages and oversize batches propagate. Single and batched writer share one
entity builder.
- IngestTelemetryFunction: payload too large -> 413, transient -> 503 with
Retry-After: 5, anything else 500.
- TableStorageChunking.MaxPropertyChars is the one 30,000-char bound.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five cross-session index tables (SessionsByTerminal, SessionsByStage,
DeadEndsByReason, ClassifierVerdictsByIdLevel, SignalsByKind) were write-only
since M5.d: no endpoint, MCP tool or web page ever read them, while the
dual-write cost a serial queue send per signal/transition on the ingest path,
a 5-13 min full scan of Signals + DecisionTransitions every two hours and five
property-filter scans per session deletion (audit 2026-09-05 F05).
Removed: the tables from TableNames.All (schema hash changes, next cold start
runs a full pass without them), the telemetry-index-reconcile queue name,
producer, envelope, handler, queue function and 2h timer, the cross-partition
Timestamp scans on the signal/transition repositories, the EnableIndexDualWrite
flag (model, config mapping, admin UI, generated wire types), cascade steps
11-15 (later steps renumbered 11-18), the deletion wrappers and the offboarding
wipe entries. DiscriminatorPkProp stays as a step class so manifests written
before this change still resume.
Order of operations: deploy first, then delete the queue pair and the five
tables in the portal (B-88h). Deleting the tables before the deploy 500s every
delete preview/enqueue and poisons offboarding.
Submodules: .claude/commands (import_session.py), internal (D-196, docs, plan).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Audit 2026-09-05 F01/F02. Analyze, VulnerabilityCorrelate, TenantAutoApprove,
IndexReconcile and ImeMsiArchive are now host-managed queue functions under
Functions/Queue bound to the DataStorage connection (identity-based, resolved
by the host): the Flex scale controller wakes an instance for a message, the
host renews the lease and owns the retry/poison ladder. The four cascade
workers with lease/CAS state stay self-polling BackgroundServices; their base
now receives one message per poll so waiting messages can no longer expire
under another worker. Terminal enrollment alerts and hardware-rejection alerts
are enqueued on notification-dispatch (channel ids only, resolved at send
time) instead of fire-and-forget sends the host could drop on scale-in.
Requires the app setting DataStorage__queueServiceUri before deploy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
withToolTelemetry mints a UUID per tool call and runs the handler in a
ToolCallContext (tool name + correlation id); apiFetch stamps it as
X-Correlation-ID next to X-MCP-Tool-Name, so every backend request of the call
(pagination scans included) and the MCP_TOOL_LOGGING tool_call line carry the
same id — a row-level join between the MCP log and the backend's request rows.
ApiError reads the X-Correlation-ID response header (body correlationId as
fallback), and the error handler names it in every branch — 5xx, quota 429,
structured 4xx and the unstructured 401/403/404/429 texts that had none. The
handler reads the envelope's `code` (errorCode as the pre-envelope fallback),
the quota body's `error`, and the log proxy's `upstreamCode`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
authenticatedFetch mints one UUID per logical call (kept across the 401 retry,
where caller headers now survive too), sends it as X-Correlation-ID — the id
the backend echoes, stamps on its request row and writes into every error body
— and tracks api_request_failed {path, method, status, correlationId} for any
final status >= 400. App Insights gets enableCorsCorrelation + W3C
distributedTracingMode scoped to the API host, so portal dependency rows and
backend request rows share an operation_Id.
fetchJson/ApiError read the envelope (error, code, correlationId, hint; the
pre-envelope `message` as fallback), describeApiError renders message +
short reference, addNotification takes an optional reference the bell renders
as "Ref …"; the error boundaries show and track Next's digest. Consumers of
renamed fields follow: delete-session parser (code ?? hint, PascalCase codes),
app-homing (code ?? reason). API_ERROR_CODES comes from the manifest, and
HOME_TENANT_UNRESOLVED plus the cascade codes are `satisfies ApiErrorCode`.
InstallsTab is the first site showing the reference.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every non-2xx body in the Functions project now goes through ResponseHelper
(ErrorAsync / BadRequestAsync / NotFoundAsync / ForbiddenAsync /
UnauthorizedAsync / ConflictAsync / InternalServerErrorAsync): 408 sites by a
pattern-exact rewrite of the CreateResponse + WriteAsJsonAsync + return shape,
50 by hand. The eight private BadRequestAsync shadow helpers are gone; the raw
500 catch blocks use InternalServerErrorAsync (sanitized message, correlation
id, MCP hint). 429s keep Retry-After via retryAfterSeconds.
Specialised error bodies where a consumer reads more than the prefix:
AppHomingDeniedResponse (kebab reason codes now Constants.AppHomingReasonCodes,
probe result), TenantConfigPatchFailedResponse (backupId, drift),
QueryBackendLogsErrorResponse (upstreamCode, statusCode, source, hint,
upstream). Twenty-one message-first 200 bodies that the old error tolerance had
let through are typed too (MessageResponse, PreviewWhitelistActionResponse,
SuccessMessageResponse). New codes: PayloadTooLarge, UpstreamError,
UpstreamTimeout, the three trial-gate codes; the agent security path
(SecurityValidationExtensions) writes the envelope with the retry window.
TypedResponseGuardTests: WriteBaseline is empty again — the anonymous
WriteAsJsonAsync ratchet now covers error bodies with zero tolerated sites.
Manifest and generated TypeScript regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every error body now leads with { error, code, correlationId }: the generic
ApiErrorResponse (hint?, retryAfterSeconds?, operation?) plus the specialised
McpQuotaExceededResponse (reordered; `message` became `error`),
DelegatedSlotLimitReachedResponse (+correlationId) and the new
SessionDeletionRejectedResponse (replaces the anonymous delete arms). The
correlation id is stamped by the writers — ResponseHelper.ErrorAsync and the
new ApiErrorWriter for the middleware side (auth 401, policy 403/503, rate
limit 429, MCP quota 429, global exception 500) — never by call sites.
exceptionType and rateLimitInfo leave the wire; Constants.ApiErrorCodes is the
code vocabulary and is exported as the manifest section `apiErrorCodes`
(API_ERROR_CODES for web + MCP). CorrelationIdMiddleware names the readable
headers via Access-Control-Expose-Headers.
Guard: TypedResponseGuardTests no longer tolerates anonymous error shapes;
the 458 legacy sites in 149 files are frozen in WriteBaseline to melt down.
New facts: envelope-prefix reflection, ApiErrorWriter body/header pins,
exact-shape pins for the generic envelope and the changed specialised bodies.
Reverses the "error bodies stay anonymous" clause of D-043 (decision entry
follows with the docs stage).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first cut refused every reader-zone-fallback endpoint. The DeviceSetup line is
written before the agent starts and is backlog on every device, so that silenced
ANALYZE-ESP-001 for all agents without an era anchor (control sessions 2708e6b4 and
18d80f9c came back "not measurable" after the deploy). Two fallback endpoints share the
writer-zone error and cancel it; only a span mixing a fallback endpoint with an anchored
one (or with "now" for an open phase) is unmeasurable — the a7140f98 shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hybrid_login_pending now measures the absence of a real-user desktop after a reboot
(cancelled by the DesktopArrivalDetector or a real JoinInfo user), is never armed on
WhiteGlove devices (resume marker, Part-2 flag, .part1- archive) and reports the
JoinInfo placeholder as a fact only — on a Hybrid join the AD sign-in never replaces it.
New EntraUserAffinityDetector: armed at the real-user desktop on Hybrid devices, the
first IME-TOKEN-SUCCESS line after the desktop posts ime_user_token_acquired; ten
minutes without one post entra_user_affinity_pending with the IME token-failure codes.
ImeLogTracker exposes raw per-line token callbacks past the historic-replay gate;
HelloTracker surfaces User Device Registration 304/305 as device_registration_event.
ANALYZE-ID-004 v2.0.0 keys on those events (event_type not_exists gained a branch),
GATHER-DEVICE-006 follows, GATHER-DEVICE-007/008 collect the Entra operational log.
Constants + MCP catalog gain the five types (D-072), the guard test learns the
multi-line OnTraceEvent shape, gather_* outputs pass the MCP filter gate by prefix.
CMTrace: ImeLogEraPreScan/CmTraceEraTable anchor the backlog era that holds the
bootstrap execution record against the Deployed install marker (0.13-0.36 s in the
field), whole era or nothing, sibling files by local time; origin era-anchored.
Backend phase_duration/app_install_duration refuse a reader-zone-fallback endpoint.
Decisions D-190, D-191. Session a7140f98.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A service principal (idtyp=app, typically behind a federated credential) is admitted only
with the access_as_application application permission and then keys every role table,
binding, audit actor and throttle bucket as app:<client-id>. Caps at resolution: Viewer in a
tenant, DelegatedReader on managed tenants, never a platform role; the roleless catalog tiers
are closed to applications except GET auth/mcp. MCP guard keys on the same principal key.
Members / MCP Users / Delegated Access get a User / Service principal switch; usage views
badge App; McpServicePrincipalFirstSeen ops event and PrincipalKind request dimension.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A tenant in the owned group of a permanent-Pro tenant is effectively Pro with source "msp":
badge "Pro (MSP)" (also over its own Pro), the Pro entitlement set without the delegation
right (no transitive delegation, no slots), no trial CTA. Nothing is written for that —
TenantConfiguration.ManagedByProTenantId is a load-time projection from a process-wide
index over the Tenant Groups table (owner tier read raw, never through the config service),
so the end of the delegation, the manager losing its permanent tier, or its offboarding
revert automatically. The one write is the retention grace anchor (ProDowngradedUtc),
stamped by ProConferralService from the four paths that end a conferring relationship.
- FeatureEntitlementCatalog.Resolve → EditionResolution { Edition, Source, OwnPro };
Get(resolution) returns ProViaMsp for conferred Pro; the 3-argument ResolveEdition is
gone so every caller resolves from the config (incl. the projection)
- DelegatedAdminService home gate checks the DelegatedAdminAllowed entitlement, not the edition
- TrialExpirySweep reads through the config service: a managed tenant's expired trial emits nothing
- feature-flags + plan response carry editionSource; config/all projects payingCustomer and
managedByProTenantId; shared manifest + web/MCP codegen regenerated
- PayingCustomer: GA-only sales bookkeeping flag on the plan endpoint, no entitlement effect
- web: "Pro (MSP)" label/tooltips, plan section badge, delegated-access hints, tenant
management chips + paying checkbox
Tests: catalog matrix, ManagedTenantProIndexTests (incl. mutual management without recursion,
fail-closed on storage error), ProConferralServiceTests, projection on every config read path,
feature-flags payload, parity facts, serialization (projection never stored), plan transitions,
edition gate, sweep. Backend 5902 green, web tsc/vitest/lint clean, MCP tsc/vitest green.
Concept doc + D-188 in internal (gitlink); customer docs in the docs repo (MCP redeploy needed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Click a bucket to narrow the map to it, add more with further clicks, Show all
or the last toggle returns to the full map; a Color-by switch resets it. Camera
fit and marker radius scale stay on the full location list so filtering never
moves or rescales the map. Customer docs updated in the docs repo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL flagged the new denial flow (cs/log-forging #83-#87): header values and the request
path travel from the deny branch into a log line, the ops-events table and the alert
channels. Headers were already stripped of control characters and capped; the path was not,
and Request.Path is percent-DECODED, so %0A arrives as a newline. BuildPrivilegedDenial now
cleans method and path (LogSanitizer) and caps the path at 256 chars, once, before any sink.
Test pins CR/LF stripping and the cap. The remaining alerts are the known false-positive
class (structured ILogger sinks with sanitized discrete properties).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live check after the x29 deploy: a KQL syntax error came back as 502, and the MCP sanitizes
every 5xx down to the error code — the syntax message and the forwarded upstream JSON were
invisible, exactly the parity loss the proxy exists to avoid. Upstream 4xx (other than
401/403) now maps to 400 with message, hint and `upstream`; 401/403 (the managed identity's
missing read grant, with a hint naming the role) and store 5xx stay 502. The MCP's structured
4xx renderer appends `upstream` as a fenced block (capped), and keeps sanitizing 5xx.
Tests: status mapping theory, hint texts, upstream rendering on 400 and non-rendering on 502.
Backend 5849, MCP 693 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 9eb8cac5 filters covered gitlink bumps and prose but not the
workflow files themselves, so every edit to build-agent.yml or a deploy
workflow still cost a full CI and CodeQL run. Neither reads any other
workflow: CI runs the suites and drift guards, CodeQL analyzes C# and
TypeScript. paths-ignore cannot negate, so each file lists the other
twelve workflows explicitly and leaves itself out; .github/scripts and,
for CI, .github/codeql join the list for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
build-agent.yml gains an optional release_notes input; the release body opens with
"What's new" from that input, or a generic fallback linking to the docs changelog
plus a marker the operator tooling uses to anchor the next notes, so releases
dispatched by hand leave no gap. --generate-notes dropped (pull requests only);
a compare link to the previous tag replaces it. Plan-first rule: plans live in
internal/work/plans from the first draft. Gitlinks: internal, .claude/commands.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Any signed-in user holds a token the GA-only routes refuse with 403, so the assume-breach
alarm was a push-flood vector: one event per caller AND route per hour, multiplied by
Flex instances. PrivilegedDenialReporter now keys the throttle on the caller alone (a sweep
over the whole GA surface is one event; every attempt still lands in the Warning trace) and
carries a fixed per-instance window budget: 5 events per hour, then ONE Critical storm
marker (same event type, so the same Telegram rule pushes it) and trace-only silence until
the window resets. Worst case per instance and hour: 6 pushes. Global Reader slips count
against the budget too. Tests pin the per-caller throttle, the storm marker and the Reader
accounting; D-185 amended.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One call replaces the six fetches of the backend-version skill: backend /api/health vs
versions/backend.json, portal /version.json vs versions/web.json, this server's own build
(build-info.ts, now shared with /health and the boot line) vs versions/mcp.json, and the
agent release manifest. Per component: live, manifest, drift (in-sync | drifted | unknown |
not-applicable) with a note that names the missing half — a manifest 404 is "never deployed
since manifests exist", never a silent in-sync; failed fetches land in partialErrors
(allSettled). The well-known hosts join config.ts as the URL guard requires. Platform scope
(GA + Global Reader); catalog snapshot updated. The local git comparison stays in the skill.
Closes the build of backlog x29 (all four phases); deploy + live verification tracked as
backlog `sch`, the user-side RBAC / app settings / alert rule as `ket`.
Tests: deployment-state (drift arithmetic, fan-out, 404, unreachable), MCP 691 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The report ZIP a tenant admin submitted and the preserved diagnostics copy could only be
fetched with a storage key through an az script. They now follow the diagnostics ticket
model: mint under the caller's JWT, download with a short-lived HMAC ticket, bytes proxied
through the backend — a storage SAS never leaves it.
Backend
- DiagnosticsDownloadTicket gains a purpose parameter (default diag-dl-v1, new
report-dl-v1): a ticket for one download surface is rejected on the other (wrong_purpose).
- POST global/session-reports/download-ticket (GlobalReadOrAdmin, same tier as the portal's
download-url): flat-name guard, existence check, best-effort size, ticket bound to the
caller's home tenant; reuses DiagnosticsDownloadTicketResponse (destination SessionReports).
- GET global/session-reports/download (PublicAnonymous, ticket is the sole authority):
per-IP limit + decode via the new TicketDownloadPrelude (shared with the diagnostics route),
streamed from the session-reports container with BlobStorageService.OpenReadAsync.
- Constants.BlobContainers.SessionReports replaces three duplicated literals.
- Pins: catalog completeness/JWT-exempt theories, auth-skip, no-store, purpose tests.
MCP
- get_session_report_download (platform scope) mirrors get_session_diagnostics: kind
(session-report / preserved-diagnostics / diag-files-report), size, download URL, expiry,
instructions; catalog snapshot updated.
Submodules: session-report-debug skill on the tool, fetch-report-zip.sh deleted; plan/todo.
Tests: backend 5839, MCP 683, solution build green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
POST /api/global/raw/logs (MCP query_backend_logs) now takes `source` = backend | web | mcp
and `budgetSeconds` 5..180 (default 30). Parity with the az CLI was already by construction
(same Kusto REST endpoint, verbatim query); the four remaining deltas were properties of the
proxy and are gone (D-186):
Backend
- LogQuerySources (Shared vocabulary, exported as manifest section `logSources`) and
LogQuerySourceCatalog: APPINSIGHTS_APP_ID → backend, APPINSIGHTS_WEB_APP_ID → web (both
api.applicationinsights.io), MCP_LOG_ANALYTICS_WORKSPACE_ID → mcp (api.loganalytics.io).
A missing setting makes one source answer 503 naming it, never the others.
- AppInsightsQueryFunction: injectable IHttpClientFactory (named client without timeout) and
TokenCredential; one CancellationTokenSource per request plus `Prefer: wait=<budget>`
upstream, 504 names the budget; typed QueryBackendLogsResponse keeps the Kusto
tables[].columns/rows verbatim (JsonElement cells) and adds source, budgetSeconds,
elapsedMs and `partial`/`partialReason` when the store cut the result (previously
swallowed); on failure the full upstream error JSON rides along as `upstream`.
- TypedResponseGuard: the last SerializeBaseline entry is gone (typed request record).
MCP
- query_backend_logs: source (z.enum from the generated LOG_SOURCES), budgetSeconds, a client
signal of budget + 15 s, rawTable result cap, description with per-source table hints.
Submodules
- .claude/commands: backend-logs / ux-insights / mcp-telemetry on `source`; query-appinsights.sh
deleted (35 call sites rewritten); az only for the live container tail.
- infra: migration scripts carry the two new app settings and role assignments
(Monitoring Reader on the portal component, Log Analytics Reader on the MCP workspace).
- internal: concept doc backend/log-query-proxy.md, D-186, docs and plan updated.
Tests: catalog resolution, budget clamp, Kusto parsing incl. PartialError, error extraction,
wire parity of the new response; backend 5832, MCP 683, web tsc, solution build green.
Before live use: grant the two roles and set the two app settings (see the plan file).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reverts the HasAccountSetupActivity tightening from f0a3f043: measured on 485 Classic
(SkipUser=false) sessions, 96 see a single Shell-Core 62407 before any AccountSetup
progress and 75 of them complete AccountSetup only through the user-apps-settled synthesis,
which needs that exit remembered as the edge. Windows confirmed categorySucceeded in 0/485,
so the synthesis is the load-bearing completion path; presence semantics stay and are
documented as such.
UserEspKeepAwakeHost instead gets an additive progress probe
(ProvisioningStatusTracker.HasAccountSetupProgress via EspAndHelloHost) and releases on an
EspExiting that arrives after the AccountSetup registry left notStarted — the real user-ESP
page exit (progress precedes it by >= 1 min fleet-wide; the Device->Account handoff exit
always precedes progress). AccountSetupProvisioningComplete no longer releases; desktop
arrival is deliberately not a trigger (explorer.exe ran before the final exit in all 385
two-exit sessions). Sessions with only the handoff exit hold until the safety cap. Release
reason user_esp_exited replaces account_setup_complete. D-183 rewritten.
Tests: host (release on exit+progress, handoff exit holds, completion no longer releases,
exit before engage ignored), tracker (presence stays true / progress false on the pre-written
all-notStarted JSON). Solution build 0 warnings; agent suite 2823 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 2026-09-04 audit found no bypass of the GA boundary, but a failed attempt left
only a Warning trace without identity, and a non-GA tools/call for a GA-only MCP tool
never reached the backend at all. This makes the boundary observable without adding
a second alarm writer (D-185):
Backend
- PrivilegedRouteDenied (Security): raised from the policy middleware's deny path for
every 403 by an authenticated caller on a GlobalAdminOnly route. Critical for a
caller without a platform role, Warning for a Global Reader. Details carry upn, oid,
tid, client source, MCP tool name, correlation id, reason and policy. Throttled one
hour per caller+path per instance (PrivilegedDenialReporter, IPrivilegedDenialReporter
seam). PolicyResult now surfaces the denied tier; the DENIED log line gains oid, tid,
clientSource and mcpTool.
- GET global/raw/access-probe: no-op GlobalAdminOnly route whose denial IS the signal
(AccessProbeResponse DTO, catalog entry, parity test).
- Defense in depth: AppInsightsQueryFunction and both TableQueryFunction triggers re-check
IsGlobalAdmin in the body (RawGlobalAdminGate); an empty RequestContext fails closed.
- Dual-register: OpsEventTypes.All, OpsAlertRulesSection.tsx, regenerated manifests.
MCP
- ga-tool-probe.ts: before dispatch, a caller without the GlobalAdmin role naming a
strictGa tool leaves an unconditional [mcp-security] line (tool, upn, scope) and fires
the access probe with X-MCP-Tool-Name; the SDK answer stays "Tool not found".
- GA_STRICT_TOOL_NAMES exported and pinned equal to the registered GA-minus-Reader set.
- debug-session prompt names query_backend_logs/query_table only for strictGa (Readers
were told to call tools they cannot see); tool_call_rejected lines carry scope + upn;
config-tool comment corrected against the actual catalog tiers.
Tests: reporter (severity, details, throttle, fail-soft), middleware policy surfacing and
IsPrivilegedDenial/BuildPrivilegedDenial, Program.cs middleware order source scan, raw
gate 403, probe catalog + JWT pins, wire parity; MCP probe unit tests, Global Reader over
HTTP on both eras, runtime refusal fires the probe, prompt gate.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Agent: ProvisioningStatusTracker.HasAccountSetupActivity now requires at least one
AccountSetup subcategory past notStarted. Windows writes AccountSetupCategory.Status with
every subcategory notStarted BEFORE the Device-ESP page exits, so the presence check
confirmed the Device->Account handoff (Shell-Core 62407) as the final ESP exit; the
user-apps-settled synthesis then completed AccountSetup and released the User-ESP
keep-awake minutes before the real exit (fleet: 77 % of keep-awake sessions, median 3 min;
on battery that is enough for Modern Standby). D-183.
Rule engine: filterField/filterOperator/filterValue now apply to event_data and
event_data_array as a same-event filter (shared ApplyValueFilter with event_count).
ANALYZE-DEV-012 v1.1.0 excludes backfilled pre-session episodes (backfilled equals false);
a second independent condition could not, since each condition scans all events on its
own. D-184. Schema, Shared model, MCP authoring guide and generated types follow.
Tests: ProvisioningStatusTrackerAccountSetupActivityTests (the verbatim all-notStarted
registry JSON), SleepEpisodeRuleTests (+3 backfill cases), RuleEngineEventDataFilterTests.
Solution build 0 warnings; agent suite 2820 and backend suite 5791 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every "chore: bump internal gitlink" commit ran the full CI matrix and
both CodeQL analyses, because a gitlink bump reports the bare path
'internal' (no trailing slash) and the existing 'internal/**' filter
does not match it. Both workflows now ignore the bare and nested forms
of the three private submodule paths plus markdown, LICENSE and the
.github metadata files. CodeQL's weekly unfiltered schedule remains
the backstop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- .gitmodules: docs -> internal, repo renamed to Autopilot-Monitor-internal; the OKF bundle now lives at internal/docs/
- .claude/CLAUDE.md rewritten: where knowledge lives, working-file rules, hard rules; imports the private half from internal/CLAUDE.md (silently absent without submodule access)
- .claude/architecture-principles.md moved into the internal bundle (reconciled against the code)
- tasks/ (gitignored) retired; working files live in internal/work/
- doc path references docs/<bundle> -> internal/docs/<bundle> in code comments, skills gitlink, codeql paths, build script
- rule descriptions no longer cite an internal doc path (customer-visible text); rules/dist regenerated
- public tree no longer names private operator tooling
- .claude/commands gitlink: skill docs point at the new paths
A domain typed into the cross-tenant filter only became a tenant ID when a suggestion was
picked; submitted raw it was dropped by the fetch (not a GUID) while the client scope compared
it against session tenant IDs, leaving zero rows and "No sessions found for this tenant yet".
Submit now resolves GUID / exact domain / unique substring, updates the box to the ID, fetches
with the resolved value, and the empty state says "No tenant matches" for unresolvable input.
The search box never matched the tenant column: tenant ID and domain are now part of the
searchable text and the suggestion dropdown (cross-tenant only), sourced from one
tenantId-to-domain map shared by table and filter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two tenants clicked the migration banner, consented the new app, and were refused by the
role-superset probe without ever learning why: the previous app's service principal holds a
tenant-side add-on grant (DeviceManagementScripts.Read.All) that no re-consent can produce, and
the grant command on the Optional Graph capabilities page pre-fills the homed (previous) app.
Backend: the probe now separates manifest roles (still propagating after consent ⇒ transient,
the retry converges) from add-on roles (permanent ⇒ MissingRoles names them). consent-status and
access-check carry appHomingPending + appHomingMissingRoles; the manual flip's probe payload
carries missingRoles. The access-check invalidates the Graph caches BEFORE the homing probe so
"Detect existing access" judges a fresh primary token after the admin's grant.
Web: TenantConfigContext learns the blocking roles from either response and the post-consent
poll keeps going while the switch is still converging (previously it stopped on the first
legacy-side reconcile and left the tenant un-switched). Settings → Autopilot Validation shows a
"One more step" banner with the roles, the grant command pre-filled with the NEW app's client id
and -Permissions, and the re-check button; the admin flip error names the roles too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent's 48h emergency break is wall-clock, so devices powered on after a shelf
period fire it days after the sweep already terminalized the session (2026-09-03
audit: 224 of 269 breaks in three days were such late cleanups from one kiosk tenant,
all Warning, burying the real ones).
- ReportAgentErrorFunction.ClassifyEmergencyBreak: session already terminal
(Succeeded/Failed/Incomplete) => Info + lateCleanup=true; open, unknown or
unreadable => Warning (historical semantics). Row read is best-effort.
- OpsEventService.RecordAgentEmergencyBreakAsync takes the severity and stamps
sessionStatusAtBreak/lateCleanup into message and details.
- Timeline event agent_emergency_break stays Warning (classifier rule 3 keys on type).
- SessionOwnerBindingObserver: document that the ops-event throttle is per Function
instance (IMemoryCache) and deliberately so.
- Tests for both branches, the fail-safe path and the pure rule; docs submodule bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Performance Map gets a "Color by" selector (enrollment duration, success rate,
API latency, DO peer efficiency, App-Load-Score) and a legend under the map. One
bucket definition per mode (mapColorModes.ts) feeds both the Leaflet markers and
the Location Performance table badges, so map, legend and table share thresholds.
The map's duration scale moves to the table's 0.8/1.0/1.2/1.5 ladder.
Also: page.tsx uses the generated wire types instead of drifted local interfaces,
formatThroughput comes from lib/formatting, marker tooltips show latency and DO share.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The four README screenshots were still from the private preview (old blue
logo, preview banner). Retaken in the light theme with the current logo,
tenant view only, no banner. Fleet Health uses the 90-day window; the geo
map has too few locations in the own tenant to read as a world map.
Also stores the GitHub social preview as source (HTML composition plus the
anonymized dashboard capture it embeds) and the rendered 1280x640 PNG.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Root LICENSE is now the verbatim AGPL-3.0 text so GitHub detects the license
instead of showing "Other"; the split model (MIT for Agent + Shared) is
explained in the README and each component keeps its own license file.
- README: MCP server in the component list, an "AI Integration (MCP)" section
with example prompts and the server URL, and a neutral Feedback section.
- SECURITY.md routes reports to private GitHub Security Advisories.
- CONTRIBUTING.md is a feedback routing page (issues, discussions, security,
Report Session button), CODE_OF_CONDUCT.md is short and neutral.
- Issue forms for bug reports and feature requests; blank issues disabled.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- /about: "Common Questions" section (15 user problems → feature answers) from one source array, mirrored as FAQPage JSON-LD
- root layout: SoftwareApplication JSON-LD becomes an @graph with Person (author/publisher), Organization glueckkanja AG (provider), WebSite, featureList, softwareHelp
- public/llms.txt with portal, docs, and source links
- robots: explicit allow block for GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User, Google-Extended
- drop the unverified "policy conflicts" claim from the analyze-rules feature card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- TenantConfiguration.McpUsagePlanOverride: a SectionUsagePlans plan name pinned to a whole
tenant (members' default user plan + organization windows); precedence per-user override >
tenant override > edition default; never changes the edition
- PATCH config/{tenantId}/plan accepts mcpUsagePlan (validated against the definitions,
400 on unknown names; null/blank clears), audited, echoed as mcpUsagePlan/mcpUsagePlanOverride
- Tenant Management: "MCP usage plan (override)" select fed by global/config/plan-tiers
- Quota 429: every Community block now says Community is sized for occasional use and Pro
lifts the window; managed-tenant block names whose plan governs it
- Field in BaseDeniedFields/RevertProtectedFields/projection, Store/Map, manifests regenerated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase A — the budget follows the data
- A delegated (MSP) read is charged to the MANAGED tenant's organization MCP budget and checked against
its plan; the caller's own budget still follows the home tenant. 429 names the managed tenant (domain)
and the Pro upgrade path; new McpQuotaExceededResponse.targetTenantId.
- Bounded fleet aggregate (global/sessions, global/stats/sessions): exhausted managed tenants are excluded
softly — RequestContext.AllowedTenantIds is narrowed (never widened, never null; pinned) and echoed as
quotaExcludedTenants. Global Admins stay exempt and are tracked on their home tenant only.
- Tenant Group flag ChargeHomeTenantQuota (PATCH global/tenant-groups/{id}, audited per tenant): reads
through the group are charged to the assignee's home tenant (operator-managed service groups).
- McpTenantUsage rows carry UserPrincipalName / HomeTenantId / LastRequestAt; new
GET metrics/mcp-usage/organization (budget by account incl. delegated readers) rendered on MCP Usage.
- MCP: get_fleet_overview (delegated + platform) over the two subset-tier routes; error handler names
the managed tenant. Other delegated tools stay tenant-mandatory.
Phase B — delegated tenant slots
- EditionEntitlements.MaxDelegatedTenants (Community 0 / Pro 2), TenantConfiguration.MaxDelegatedTenantsOverride
(GA-only via PATCH config/{tenantId}/plan { maxDelegatedTenants }), DelegatedSlotService counting distinct
managed tenants per home tenant through identity bindings (+ pending invitations + 24 h holds), enforced
fresh on grant / group assign / add-tenant with 409 DelegatedSlotLimitReached; GA UI offers
"raise to N & retry"; GET global/delegated-slots/{tenantId}; feature-flags entitlements.maxDelegatedTenants.
Phase C — self-service delegation
- One implicit Tenant Group msp-{tenantId} per managing tenant (OwnerTenantId on the meta row);
DelegationInvitations table (invitations + slot release holds); DelegationInviteTicket (HMAC, 7 days,
locates the row only — the accepting tenant is always the JWT tenant; one-shot via ETag).
- DelegationSelfService: ordered accept chain (distinct codes), remove/revoke with 24-hour slot hold, dual
audit and live-stream disconnect, assignees bound to tenant membership, "who manages my tenant" view;
GA release-hold escape hatch. No delegations/* route carries {tenantId} (pinned).
- Web: Settings → Tenant → Delegated Access, /delegations/accept?token=, AuthGate keeps the deep link
across the activation detour, Tenant Management holds + release, owned groups read-only in GA UI.
- Offboarding wipes both sides (documented two-bucket membership); 90-day sweep of terminal rows.
- Trust pages (terms, privacy) and generated wire types updated; docs submodule bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL flagged the re-save in AuthGate (js/clear-text-storage-of-sensitive-data,
alert #82): the value flowed back into sessionStorage from the consumed
return URL. The link only has to survive one more sign-in round trip, so the
re-home path now reads it without clearing and never writes it again.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Session details, diagnosis, annotations, fleet health, geographic performance,
SLA, software, usage metrics, audit log, system health, IME log patterns and the
rules pages link to their guide from the end of the header control group.
Navbar help links use the DOCS_PATHS catalog (service announcements no longer
goes through a redirect).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Backend: the app-homing consent probe now requires the primary app's Graph
roles to be a superset of the legacy app's, so a mere sign-in consent
(User.Read, no application role) can never auto-flip a tenant onto an app
without the device-validation permission.
- Web: dashboard banner for tenants still on the previous app (per-tab
dismiss, docs link); docs links on the settings funnel banner, the flipped
banner and the failed-sign-in screen; immediate re-homing after a redirect
sign-in on the other app (one hop per tab, never after a cross-app
fallback); the www -> portal handover passes the learned app as ?authapp=.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dashboard selection could grow across pages without limit and a bulk delete was one click on the red button. The effective selection is now cut to BULK_MAX_TARGETS (100, the largest page size) and every pick path (row, Shift range, page checkbox) fills only up to the remaining room. The delete dialog lists every target and, above BULK_TYPED_CONFIRM_THRESHOLD (10), asks for the word DELETE before the button arms. Block stays one click because it expires after 24 hours.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CompanyName joins ContactEmail as the tenant contact profile: optional on Community,
both required by the self-service trial start (one 409 ContactProfileRequired verdict
naming every missing part). GA plan assignment stays soft (admin warning). Feature
flags expose companyNameSet as a boolean only; TenantTrialStarted carries companyName.
Web: Contact section field, Plan gate and dashboard banner name what is missing,
admin panel/report/search know the company. Privacy page lists the new datum.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The organization-quota card called toLocaleString on undefined when the
backend predates the tenant quota (deploy window, local dev against prod),
tripping the error boundary. Tenant fields are optional on the client now;
the card renders only when they are present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- SectionCardHeader replaces 33 hand-copied card headers across settings, rules and admin pages
- DocsLink + lib/docsPaths: every tenant-facing section links its documentation page (new tab)
- docsPaths guard test encodes the GitBook URL rule (group slug, not folder name)
- delete dead MaintenanceSection duplicate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every X-Client-Source: mcp request is now charged against two budgets: the
caller's own daily/monthly windows (per-user override or tenant edition plan)
and the organization-wide daily/monthly windows of the token's tenant, which
always follow the tenant's edition plan so a per-user override never widens
them. Ten accounts no longer mean ten times the budget.
Tenant counters live in the new McpTenantUsage table (PK = tenantId,
RK = {yyyyMMdd}_{oid}: one partition range per check, no hot row, never a
property scan over UserUsageLog); exact-PK offboarding bucket and 90-day
retention included. PlanTierDefinition gains nullable tenant limits (null =
catalog fallback: Community 300/9000, Pro 3000/60000; 0 = unlimited). The 429
body carries level=user|tenant and a message naming whose budget it is,
X-MCP-Quota-Tenant-* headers ride along, and mcp-usage/me exposes both
budgets (wire types regenerated).
MCP server: the quota 429 body was rendered as Error: undefined because it
has no error key; it now gets a dedicated block with the reset time and a
level-specific suggestion. Web: Usage Plans edits the tenant limits; the
self-service MCP Usage page renders both budgets (the quota node was never
displayed before).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
McpAccessPolicy.AllMembers returned Allowed for any valid JWT, so a Progress
Portal end-user without a role could open an MCP session. The AllMembers path
now requires an effective role (Admin / Operator / Viewer) in the token's
tenant, resolved by the new TenantMemberRoleResolver that the policy
middleware shares (table first, Entra app-role claim when the tenant opted
in). Explicit grants (platform role, delegated scope, enabled McpUsers row)
and the Disabled-row kill-switch keep working in a policy-independent order;
auth/mcp and auth/me hand the token's app roles through.
The MCP Users settings section shows the user list under AllMembers too, as
the per-user override list (individual usage plan, disable = block).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fixture waited 5 s per posted signal and the scenarios 10 s for a
terminal stage. Those are deadlock guards, not performance assertions:
every applied step performs 5-6 WriteThrough + Flush(true) writes and 2-3
atomic renames synchronously on the ingress worker, and on a cold GitHub
runner volume the first scenarios after the parallel phase still blew the
5 s budget even though the collection is already serialised (run
33581374985: the first four scenarios in run order failed, the other nine
passed; the rerun of the same commit was green). One constant,
EnrollmentOrchestratorFixture.SettleTimeoutMs = 30 s, now covers the
per-signal, terminal-stage and uploader waits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`dotnet test --verbosity quiet` also sets the console logger to quiet, which
drops the "Error Message" block of a failed test — run 33581374985 showed
only four "[FAIL]" lines for the V2 Core scenario flake and nothing about
which fixture budget blew. An explicit `--logger "console;verbosity=minimal"`
keeps the build quiet but prints the failed tests with message and stack.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server already strips SignalR Quota, Poison Queues and every endpoint URL
for non-Global-Admins, but the page mirrored the Global-Admin VIEW only for the
build blocks, so demo mode still showed the full operator report. The new
lib/healthCheckView.ts applies the same rule presentation-side (cards + URL
detail rows), and both card render paths share one HealthDetails component
that puts URL values on their own line under the label instead of wrapping
them right-aligned next to it.
The healthy agent-binaries message no longer advertises the legacy blob; that
account stays in the Global-Admin-only details.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The per-client opt-in stays available on the server; this repo's .mcp.json no longer sets it,
so operator sessions run compact too and pay no indentation tokens. Set the header in your own
client config when you need to read raw results.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Compact JSON stays the platform default: measured with an OpenAI-family tokenizer on realistic
payloads, 2-space indentation costs 14-46 % more tokens (2-2.7 tokens per line for newline,
indent and colon spacing) — gzip removes it from the wire, not from a model's context. A human
reading results in an interactive client opts in per client with `X-MCP-Pretty: 1`; the access
guard stores the preference on the caller context and stringifyResult honours it. This repo's
.mcp.json sets the header so operator sessions in Claude Code get readable output. The
MCP_PRETTY_JSON environment switch remains the global escape hatch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- get_session_diagnostics no longer embeds the ~5k-character ZIP layout per ticket; it points at
the static diag_zip_layout resource (read once per conversation). Prompt text updated.
- get_resource accepts `section` to read one top-level part of the rule-authoring trio; the
section list in the description is derived from the live catalogs. RESOURCE_NAMES now lives in
resource-catalog.ts as the single source of the name enum and the ResourceName type (the inline
z.enum baseline entry is retired with it).
- search_knowledge no longer repeats type/title inside metadata.
- search_events walks up to three candidate event types concurrently (TYPE_WALK_CONCURRENCY);
pages within a type stay sequential, results stay in candidate order, budget/truncation
semantics unchanged.
Deliberately not done (operator decision): a longer undici keep-alive (runtime dependency for a
single-digit-ms in-region gain; the backend sends no Keep-Alive hint) and precomputed tool JSON
schemas (7-12 ms per request on a dev CPU, would need SDK-registration patching).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
30-day tool telemetry: callers pass fields explicitly in more than 90 % of get_session_events /
query_raw_events calls, and reads filtered by eventType keep the payload about half the time
(the ime-decompile skill reads data.msiDownloadUrl after an eventType filter without fields).
So the lean default (no data / no DataJson) now applies only to unfiltered timeline reads and
announces itself in-band (omittedFields + omittedNote); a read filtered by
eventType/severity/source returns full events as before. Never worse than the former default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Backend
- SingleFlightCache<T>: per instance, TTL, single-flight, failures never cached, expiry sweep, optional single slot
- Rule catalog caches in TableStorageService.Rules.cs: gather rules / rule states / IME patterns per partition (60 s), every Store/Delete invalidates, hand-outs are clones (GatherRule/ImeLogPattern/RuleState.Clone()); GatherRuleService and ImeLogPatternService seed behind a gate and read global/tenant concurrently; GetAgentConfig reads rules and patterns concurrently
- Tenant-id snapshot (60 s) shared by every cross-tenant fan-out; the paged session list no longer rescans TenantConfiguration per page
- BoundedFanOut + DrainSessionsIndexWindowAsync: one PartitionKey-anchored, RowKey-bounded, projected query per tenant; GetAllSessionStatsAsync drains with SessionStatsProjection under a 30 s single-flight cache; GetGeoWindowSessionsAsync reads the index key range; GetSessionsByDateRangeAsync fans out per tenant
- App summaries: cross-tenant windowed projected reads fan out per tenant over a union projection and share a single-slot 60 s snapshot (Installs tab pair, app detail, usage refs); days clamp on global/metrics/app
- CancellationToken on the uncached geo/date-range reads (IMaintenanceRepository)
- EventFieldProjection accepts data.<key> slices
- BackendStartupMs measured from Main (StartupClock) instead of Process.StartTime, which carried the Flex placeholder lifetime
Web
- Dashboard stats: 30 s SignalR debounce in the GA aggregate scope (every tenant's events drove a cross-tenant drain every 3 s)
MCP
- Compact JSON tool results and resources (MCP_PRETTY_JSON=true opts back in)
- DEFAULT_FIRST_PAGE_SIZE 50 for every paginated tool; the auto-exhaust budget is row-based (2000 rows) so scan coverage does not shrink
- Lean first-page projections: get_session_events without data, query_raw_events without DataJson; announced in-band (omittedFields/omittedNote) and restorable via fields; a continuation keeps the nextLink projection
- get_session_summary fetches the timeline with the triage fields plus data.<key> slices for its guards
Docs: docs/backend/read-caches-and-cross-tenant-fanout.md (submodule 11fc393).
Deploy order: backend, web, then MCP (the summary's data.<key> projection needs the new backend).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opt-in Select button (Admin Mode) adds a checkbox column, row-click
toggles selection, Shift+click ranges, header checkbox selects the page,
Esc/Done leaves. Bulk actions loop over the per-session endpoints with
three requests in flight and one summary toast; no bulk endpoint.
Confirm modals list the targets. Also translates the block tooltip and
the block error alerts to English.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 2026-08-23 instrumentation cutover re-fired as per-path share regressions
(sweep:r6 lift 8x, sweep:r5_incomplete 5x) because the single stamped baseline
day already cleared the 5-hit floor while the legacy:* history was not counted.
Rule-shaped legacy rows now fold into the per-path BASELINE of sweep:{rule}
(baseline only - derived rows never become window evidence). Failed rows whose
reason is the agent's esp_failure message derive as agent:esp_failure_fallback,
matching the stamped ingest path, which removes the fake agent:failed drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The V2 Core integration scenarios drive the real orchestrator against
wall-clock budgets (5 s per signal, 10 s for Completed / uploader). In
isolation every scenario finishes in under a second; inside the fully
parallel suite on a 4-core runner the same scenarios took 4-7 s locally
and blew the budgets on two slow GitHub runners (CI runs 33564658714 and
33565773888). Serialising them after the parallel phase, like
EnrollmentOrchestratorTests and RecoveryPathTests already are, brings
them back under 700 ms without changing the budgets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A plain wrapper around dotnet restore/build that nothing referenced. Its
"next steps" advertised a local self-hosting workflow (Azurite, func start)
the project does not support, and its name collided with the real agent
release script scripts/Deployment/build.ps1 that the docs and workflows
mean by "build.ps1". Building is `dotnet build AutopilotMonitor.sln`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test code used the real test-tenant GUID, a real UPN, a real VM serial (and
its Intune-normalized form) and a real hostname as literals. None of the
assertions depended on the specific values; they are contoso/1111…/1234… now.
The one that stays is CertTenantBindingTests: its tenant GUID is stamped
inside device-cert-sample.pem and can only change together with the fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two Bicep templates — and the deployment scripts still to come — do not
belong in a public repository. They now live in okieselbach/Autopilot-Monitor-infra
(full history via git-filter-repo) and are mounted back at infra/, so the code
comments pointing at infra/mcp-server.bicep stay valid. No workflow reads
infra/, so CI is unaffected. The *.parameters.json ignore rule moved into the
submodule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screenshots and the logo lived in docs/images, which is now a private
submodule and cannot render on the public README. They are README assets
only (no doc page references them), so they move to .github/assets — the
usual home for repository-level images — and leave the docs bundle.
The architecture link pointed into the private docs; the technical
documentation is not part of the public surface any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository is public; the technical docs carried security-mechanism
gaps, incident details and operator process. They now live in the private
repository okieselbach/Autopilot-Monitor-docs-internal (full history,
extracted with git-filter-repo) and are mounted back at docs/, so every
code, skill and memory path reference stays valid.
log.md is gone: it duplicated the commit messages. index.md remains the map.
CLAUDE.md: concept docs only for invariants and decisions a later change
could silently break; commit inside the submodule first, then bump here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four Graph-backed validators ran on the unnamed HttpClient (100 s default timeout), two attempts each, sequentially, with an uncancellable token retry chain underneath: GetAgentConfig tails of 30-102 s for an agent that gives up after 30 s. DeviceValidationBudget gives the chain one 20 s token and each attempt a linked 8 s token that also bounds token acquisition; exhaustion is a transient result (503 + Retry-After) and the second attempt is skipped once the chain token is spent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The lookup scanned only the newest 100 SessionsIndex rows, so any enrollment older than that horizon answered 'Device Not Found' and a member substring search could land on a newer device. Exact serial and device name now run as server-side eq filters over the tenant partition (stored form or upper-case), newest session wins, then a point-read; the substring page remains the members/GA-only fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CWE-807: the kill switch was keyed only by the caller-declared X-Device-SerialNumber, so omitting or forging the header dodged block and kill. A block now mirrors onto the device's certificate identities (Sessions.OwnerDeviceId) as id:{guid} alias rows in BlockedDevices, resolved at block time; KillSwitchEvaluator checks serial, then identity, then version and stamps the DeviceIdentityBinding request dimension. Alias rows are hidden from listings and exempt from the legacy-key migration; unblock removes them. Session-scoped watchdog blocks were unreachable (no session id ever reached the evaluator): ingest now carries the verdict past the body parse and re-evaluates with the session id, and holds the registered serial against the block list when the header differs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Records the import-session skill: the 23-step cascade inverse, the single
replacement table over keys and strings, what never travels (Owner*,
deletion/diagnostics columns), the SoftwareInventory counter semantics,
scrubbing rules, provenance lane, and the safety model (insert-only under
the new id, key invariant, zero-leak assertion, automatic rollback).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator still listed the removed docs sections, roadmap and changelog
(all moved to docs.autopilotmonitor.com / retired), and lacked /get-started,
which the sitemap emits -- so that entry got "now" as lastmod on every build.
- PAGE_MAP now matches exactly the URLs app/sitemap.ts emits
- generator fails on a missing source file instead of using the deleted
file's last commit date
- sitemap throws at build time for a URL without a lastmod entry
- fix stale comment: the /docs redirects live in staticwebapp.config.json
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both annotation list routes (sessions/annotations/list, global/session-
annotations) accept ?q= -- a case-insensitive substring over the note and
the verdict, deliberately not the lane, author or rule ids, which have their
own filters. Table Storage has no contains, so it is applied in the same
client-of-Azure back-fill loop as ruleId (filtered-out rows never consume
page budget) and bound into the continuation fingerprint, so a cursor issued
for one search cannot be replayed against another; nextLink echoes it.
Portal: a debounced search box on the Annotations page (the setState lives
in the timer callback, not the effect body). MCP: list_session_annotations
gains `query` -> q. Response DTO unchanged, no wire break.
Why: demo sessions imported into the operator's own tenant fall out of every
dashboard/geo/SLA time window. A note ("wifi switch") plus this search is
how they are found again -- and it is generally useful for any judged session.
Deploy order: backend first, then web and MCP (the parameter is silently
ignored by an older backend).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
query_raw_events timed out twice on the same continuation and lost all work each time. Per page the
cross-session path resolved every session's tenant through a SessionsIndex full-table scan (the
EventTypeIndex row already carries TenantId), ran up to 1000 per-session queries serially, applied the
date window last and against the Azure system Timestamp, and had no deadline; the retry was identical
because the nextLink freezes pageSize and the explicit value was dropped.
Backend
- RawEventsScan: index walked in chunks of 100, 20 parallel session fetches, 15 s budget checked
between chunks; cursor always after the last fully processed chunk; QueryRawEventsResponse.Partial
marks a budget-ended page (nothing missing up to nextLink).
- Date window pushed down: Events RowKey range inside the session partition
(BusinessTimestamp.EventDateFrom/ToClause) + sound index write-time pre-filter
(startedAfter - MaxFutureToleranceHours - 1h). The session-StartedAt RowKey was rejected as a
prefilter (WhiteGlove Part 2 emits weeks later).
- Raw date filters and ordering now use the business time (RawEventTime: OccurredUtc -> RowKey ->
system Timestamp); unparsable startedAfter/startedBefore is a 400; bare values are UTC.
- Same scan sat behind search_sessions_by_event, search_sessions_by_cve and the deviceProperties path
of search_sessions: all read Sessions by point read with the tenant their index row carries
(BatchGetSessionsByKeyAsync, IndexRowKeys.ResolveTenantId); the scan-based batch helper is gone.
MCP
- followNextLink(..., overrides): an explicit pageSize (and fields on the raw tools) applies to a
continuation in every nextLink-paginated tool; pageSize schemas lose their .default(),
pageSizeForCall sends the first-page default only on a first-page call.
- scanWithTimeoutFallback on the auto-exhaust tools: one retry with a halved pageSize on the same
cursor, marked retriedWithPageSize; timeout error text distinguishes continuation calls.
- Generated wire types regenerated (partial?: boolean).
Docs: docs/backend/raw-events-scan-budget.md (design, invariants, audit of the remaining unbounded
cross-tenant aggregations with live durations), business-timestamps.md consequences.
Deploy backend first (additive field), then MCP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Internal (Autopilot Monitor)" lane is labelled "never visible to the
tenant" and sits on the session page shown in every live demo, but it was
gated on the platform identity alone and stayed on screen when the
Global-Admin view was switched off.
visibleLanes() now takes the view flag as an explicit second argument and
combines it with the identity, so the rule also covers the read-only Global
Reader. The /annotations overview additionally filters the rendered ROWS to
the visible lanes: that list is not lane-scoped by the request, so an
internal row would otherwise surface there even with the lane dropped from
the filter dropdown.
Not a permission change -- the backend filters the lane for tenant callers
regardless (GetSessionAnnotationsFunction.FilterLanesForCaller) and owns the
write matrix.
Also fixes the retention-override banner: the bare warning glyph inherited
the base 16px size against 12px text and sat below the first line, with an
mt-0.5 hand-nudge that could not hold it. Replaced by a w-4 h-4 SVG whose
box matches the text-xs line box exactly, so items-start aligns it with no
margin -- and it now matches the icon style of every other alert box.
The IME log pattern controls needed no change: canEdit already reads
isGlobalAdmin && globalAdminMode, so demo mode reaches them through the
effective flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(web): demo presentation mode for live product demos
The Global-Admin view toggle only ever reached the nav and the scope hooks.
Every GA extra inside a tenant page was gated on the identity and stayed on
screen when the toggle was switched off -- including the /health-check build
cards, whose commit hashes hyperlink into the private repo, and the Decision
Inspector button on the session detail page.
New useGlobalAdminUi() (real GA *and* global view on) replaces the bare
identity check at the five visible GA surfaces, so switching the toggle off
now yields a genuinely tenant-admin-looking view.
On top of that a hidden demo mode, armed by a self-consuming ?demo=1
(persisted to localStorage, then stripped from the address bar), forces the
global view off for every consumer without touching the stored value, hides
the toggle itself and the platform role badges, and silently replaces
/admin/* with /dashboard rather than rendering the Access Denied card --
whose text would itself reveal that a platform area exists. ?demo=0 clears
it and restores the previous setting. An unrecognised value leaves the
stored flag alone, so a typo cannot drop the operator out mid-presentation.
Admin Mode stays untouched: a tenant admin has it too, so it belongs in an
honest demo. user.isGlobalAdmin is never faked globally -- that would drag
canEditConfig along and leave a GA without an own tenant-admin role unable
to demonstrate anything.
Also hides the settings gear when neither toggle applies, which previously
opened an empty panel for Operators and Viewers as well.
Presentation only. Every rule mirrored here stays enforced server-side
(TenantConfigValidation, GlobalAdminOnly -> 403); no backend file is
touched and demo mode is not a security boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(web): refresh generated page-lastmod
Regenerated by the build (scripts/generate-lastmod.js). The committed file
had drifted: about/privacy/terms carried stale dates and /buy, /help and
/plans were missing entirely, so the sitemap under-reported them.
Pre-existing drift, unrelated to the demo-presentation-mode change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2026-09-01 WG-seal soak on post-SignalIngress-fix builds (memory:
project_wg_seal_postfix_soak_residual) found sessions whose DecisionEngine
never sealed Part 1 while the agent kept running for hours - and telemetry
could not distinguish a frozen ingress worker from a deliberate Weak
sealing-classifier verdict, because the metrics snapshots carried no
pipeline counters at all.
Every agent_metrics_snapshot now carries the block. The fields are MONOTONIC
totals plus a monotonic queue peak - deliberately no momentary queue length:
a snapshot samples the integral, so any two snapshots prove whether the
worker ran in between (a frozen worker keeps signal_pending_count growing
while processed/applied stand still; a momentary gauge would alias between
ticks). Same field pattern the snapshot already uses for the spool
(spool_total_enqueued_count / spool_peak_pending_item_count).
- signal_last_ordinal / signal_processed_total (new ProcessedSignalCount) /
signal_pending_count / signal_reentrant_total: SignalIngress worker
progress; signal_queue_peak is a new lock-free Interlocked-max recorded in
Post (fast + slow path; worker-local follow-up items deliberately excluded)
- decision_stage + decision_last_applied_ordinal: from the immutable
DecisionState - answers the WG question directly ("minutes after the
tracker's whiteglove_complete and still not WhiteGloveSealed")
- wg_sealing_level / wg_sealing_score: only after the sealing classifier
produced at least one verdict (key ABSENCE, not Unknown/0 noise, for every
non-WG session) - separates "classifier said Weak" from "never ran"
Wiring is a read-only probe chain following the imeTrackerHealthProbe
pattern: EnrollmentOrchestrator passes () => _processor?.CurrentState into
IComponentFactory.CreateCollectorHosts (new optional decisionStateProbe;
test fakes updated), PeriodicCollectorLifecycleHost combines it with its
existing concrete-SignalIngress reference in BuildSignalPipelineProbe, and
AgentSelfMetricsCollector writes the fields fail-soft (a throwing probe
degrades to a snapshot without the block - pinned by test). The snapshot
event travels the collector/event path, which is independent of the signal
pipeline it measures - field-proven by the freeze cases, whose perf/metrics
events kept flowing while the engine stood still.
No backend/web/MCP change needed: no new event type, DataJson is free-form.
Tests: 5 new collector facts (full block, absence semantics x2, fail-soft,
no-probe compatibility) + 2 new SignalIngress facts (processed total stable
across drain; queue peak survives the queue draining - the property a
momentary gauge would not have). Agent suite 2812 green, solution builds
with 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad
Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer
tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad.
Ein neuer Provider haette beide anfassen muessen.
Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider —
WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST.
SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es
keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession,
ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification).
SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService
bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern.
Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token
gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb
ist das Gate eine Server-Regel, keine versteckte Dropdown-Option:
ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE
Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der
Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter
Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann.
Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und
wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft.
Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList /
RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin,
statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson +
GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload,
NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0).
Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von
Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im
Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus
SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar,
damit ein Speichern ihn nicht still verwirft.
Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den
SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate
invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests
decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub,
der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots
OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in
AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel
wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden
hiess also: das Sales-Team bekommt auch jede Security-Regel.
Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe
NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds
bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und
vereinigt ihre Ziele.
Rueckwaertskompatibel ohne Migrationsjob:
- Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot
einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und
uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet.
- GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor
zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert.
Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher
wegdriftet.
- Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel
traegt, also verhaelt sich am Deploy-Tag nichts anders.
- Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel,
deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles.
Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr
geschrieben — aufraeumen erst, wenn produktiv Channels stehen.
Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von
RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen
Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes
Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die
auch die Tenant-Liste passiert.
Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog
registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und
wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen.
Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext
mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen
Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der
gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt
ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine
Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf
positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine
Webhook-URL in den falschen Provider-Slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend): TenantTrialStarted + Event-Payload optional pro Regel
Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der
GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den
Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event.
RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden
per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist
fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad
haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still,
ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null)
ist ein Downgrade und bleibt bei TenantPlanDowngraded.
Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein
Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst:
ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen,
und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das
Informationsniveau aller bestehenden Regeln bleibt damit unveraendert.
ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch
eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und
ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln.
Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte
<= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext
dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer
gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein
stabiler Schluessel zum Verzweigen.
Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die
Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung,
schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben
ungetestet — Suite-Konvention ist, die reinen Kerne zu testen.
Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md +
docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events
Query-Surface), und ein Teil-Commit darin haette es mitgenommen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies
The ops-events read could only narrow by category and date window, so answering
"did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and
filtering it client-side — and for the MCP tool that client is a language model,
so the question cost a category-sized response in model context.
Ops-events filter surface (mirrors the audit-log filter pattern one-for-one):
- OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters +
pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters ->
OData clauses). BOTH filter builders call the appender — the single-category /
unpaged one and the per-partition fan-out builder that adds the RowKey bound —
so "no category named" cannot silently return unfiltered rows.
- minSeverity expands to an OR-set, not a range: Table Storage compares strings
lexicographically and the severity names sort Critical < Error < Info <
Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half.
The ladder now lives in OpsEventSeverity.Rank, shared with
OpsAlertDispatchService, so "Warning and above" in a read means what it meant
when the alert rules fired.
- severity is normalised onto the canonical vocabulary and an unknown value is a
400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an
empty page that reads exactly like "no such events happened". eventType stays
verbatim — stored rows outlive the vocabulary, so a retired type must remain
searchable in the historical window.
- Every filter value is folded into the continuation fingerprint and echoed on
nextLink; extras are appended AFTER the pre-existing category/tenantId
discriminators and skipped when empty, so tokens minted before the filters
existed still fingerprint identically. The tenantId drill deliberately stays
in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing).
- MCP get_ops_events gains eventType / severity / minSeverity plus a `days`
shorthand resolved client-side to a concrete dateFrom (a backend-side "last N
days" would re-resolve "now" on every page and blow the token fingerprint);
an explicit bound always wins over the shorthand.
Fixed alongside: the paged cross-category fan-out skipped Platform (Azure
Monitor alerts) because its category list had drifted from OpsEventCategory —
written from day one, invisible to every paged reader, still visible on the
unpaged full-table path. The list is now OpsEventCategory.All plus a reflection
coverage test.
That drift had a structural cause, so the same round closes it for the MCP as a
whole: wire TYPES reached the MCP from day one, but vocabularies did not — types
are erased at runtime and z.enum() needs values, so every tool schema hand-typed
its own copy with nothing to compare against. The copies had drifted:
search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete
— both real stored statuses), both event readers offered 4 of 6 severities (no
Debug, no Trace), get_ops_events named 6 of 7 ops categories.
- The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES:
session statuses, event severities, ops categories/severities/types,
annotation lanes/verdicts) from the manifest sections listed in
MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops
vocabularies. Freshness is pinned twice — byte-equality against a fresh
codegen run in the MCP suite, and the shared-manifests-in-sync CI job.
- Tool schemas derive their z.enum() from it and descriptions interpolate the
list instead of spelling it.
- vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..])
must sit on a baseline naming why it has no backend owner (MCP-local knobs,
plus rule type / connection type / CVE risk, which have no C# constants class
yet). A new hand-typed list fails, and so does a stale baseline entry.
- OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the
OpsEventService write sites use the constants (a raw literal now fails
OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection),
the manifest exports them, and get_resource(name="ops_event_types") serves
categories + severities + types to the model from the generated copy.
Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent.
Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an
older backend gets silently unfiltered rows back and presents them as filtered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: register the ops notification channels doc in the index and log
The concept doc landed with the feature commits; its index entry and log entry
were still uncommitted in the working tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three CmTraceTimeSkewRegression fires on 2026-09-01 (one tenant, sessions
e797117b / c06d639d / d7c8032b, agent 2.0.1445, each exactly -60.0 min,
residual 0.0, 85-88 % grid-conformant) all came from the same stale leg: those
sessions are pre-provisioning, their Part 1 ran on 2026-08-20 under agent
2.0.1409 (before per-line anchoring) and left 23-26 IME samples at -60.00,
while the Part 2 leg that completed that morning is clean at -0.16. The
terminal reconcile scans the whole event partition, so the old leg supplied the
majority of the IME samples and fired against a build the device had
self-updated to minutes earlier. Real skew, 12 days old, already fixed - and
>=821 pending pre-provisioning sessions with agent < 1412 are queued to repeat
it.
Event rows carry no agent version, so the split is drawn on ReceivedAt:
ResolveEraStartUtc walks the batch stamps of BOTH sides back from the newest
one and ends the era at the first gap > EraGapHours (2.0) - above any in-leg
upload gap (self-metrics every few minutes, session_stalled at 60 min idle,
maintenance sweep at 2 h), below every pre-provisioning handover. Median, grid
residual, conformity AND the distinct-batch guard now all run on the same
era-filtered sample set; the batch counters used to come from the full scan.
SessionSkewScan carries ReceivedAt per sample and its 20k cap keeps the NEWEST
samples (chunked trim) - keeping the head would have blinded the detector on
exactly the sessions large enough to reach it. The ops event reports
eraStartUtc plus the excluded per-side counts, so an inherited leg stays
visible without being alarmed on.
Tests: 3 new era facts, mutation-proven (EraGapHours = 100000 turns exactly
those three red). The handover fact also asserts that the old leg judged on its
own still fires, so it is the window that silences it, not a moved threshold.
Backend 5334 green, Agent core 2805 green, solution builds with 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zwei Fehlbefunde des 30-Tage-Reports als Warnung im Skill verankert (SignalR-Reconnect-Ratio,
Consent-Funnel aus Frontend-Events), Datenmodell korrigiert (/progress liegt auf portal.,
dcount(user_Id) ist wegen disableCookiesUsage keine Personenzahl, pageViews.duration ist 0,
exceptions-Tabelle ist leer) und die Event-Referenz vollstaendig aus dem Web-Code regeneriert.
Alle 35 Queries der Datei einzeln ausgefuehrt, 35/35 gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:
WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).
UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.
ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.
Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.
Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.
Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.
Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.
Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): typed API response infrastructure — IApiResponse marker, wire-settings single source, widened anonymous-body ratchet (batch 0 of typed-api-contract)
IApiResponse (Shared, with SuccessMessageResponse/SuccessOnlyResponse and the
ProjectedItems/WireContract attributes for the upcoming manifest export) plus generic
OkAsync/CreatedAsync/JsonAsync<T> overloads constrained on it, so anonymous success
bodies stop compiling once the legacy object overloads go; ApiJsonOptions is now the
single source of the production wire settings (camelCase, WhenWritingNull, string
enums) used by Program.cs AND the three endpoint-test harnesses (two serialized
PascalCase-with-nulls, one JsonSerializerDefaults.Web — all could pass while the
deployed wire differed); TypedResponseGuardTests replaces OkAsyncBaselineGuardTests
with the same ratchet mechanics over two frozen baselines (44 anonymous helper sites
unchanged, plus 134 anonymous success WriteAsJsonAsync sites across 76 files —
error-shaped bodies stay anonymous by design) and a flatness/location reflection
guard (IApiResponse implementers derive from object and live in Shared, protecting
JSON key order); ApiResponseWireParityTests carries the ordinal old-vs-new
serialization proof each converted site must add
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): Sessions/Annotations/Reports/Raw/Progress success responses are typed wire DTOs (batch 1 of typed-api-contract)
34 anonymous success bodies across 24 functions replaced with flat IApiResponse DTOs
in Shared (SessionApiModels/ReportApiModels + new Annotation/Raw/ProgressApiModels) —
minimal in-place transformation, call structure/status codes untouched, declaration
order == wire order, nullable exactly where a site can emit null (WhenWritingNull);
fields= projections stay IReadOnlyList<object> marked [ProjectedItems(SessionSummary/
EnrollmentEvent)], raw table rows stay PascalCase-verbatim dictionaries (no
DictionaryKeyPolicy — pinned by test), AnnotationWire and the distress-report
aggregations now build named item types instead of anonymous ones, and shared shapes
collapse to one DTO (SessionListResponse x4, SessionAnnotationListResponse x2,
SessionReportListResponse/GetSessionEventsResponse paged+unpaged via absent-null
nextLink); 54 new wire-parity facts serialize each OLD anonymous literal (independent
fixture copied from the pre-migration site) against the new DTO ordinally, with a
null case per vanishing key; ratchet baselines shrink 44->14 helper sites and
134->122 write sites; the three distress-report test files read the now-typed
aggregation items directly instead of via reflection-ToDynamic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): every anonymous API success body is a typed wire DTO — ratchet baselines empty, legacy object overloads deleted (batches 2-5 of typed-api-contract)
the remaining 141 anonymous success sites (Admin 36, Rules 24, Config 15, Graph 5,
Metrics 16, Vulnerability 17, Infrastructure/Global/Notifications/Bootstrap/
Diagnostics 20 incl. the 429 quota body in McpQuotaEnforcementMiddleware, plus the
14 helper-call sites) now return flat IApiResponse DTOs from Shared (new Admin/
Rules/Config/Graph/Metrics/Vulnerability/Infrastructure/Notifications/Diagnostics
ApiModels; GeographicApiModels extended) — same minimal in-place transformation as
batch 1: declaration order == wire order, nullable exactly where a site can emit
null, dual success/failure sites keep their status-code logic and reuse
SuccessMessageResponse, MCP-critical key names (records/summaries/inventory)
pinned; wire item shapes that were function-local (CustomsArchive Run/EntrySummary,
CpeMappingDto, UnmatchedSoftwareAgg, AutoResolve items, LogPatternTestResult,
CveExposureSummary/SeverityBreakdown/TopCve — records rewritten as flat classes,
netstandard2.0 has no IsExternalInit) moved to Shared so the manifest export sees
them; payload types that must stay in the Functions assembly (RuleDryRun,
PreviewWhitelistEntity, GlobalAdminEntity, HealthCheck, GlobalNotificationDto,
Backfill/Reclassification/MaintenanceResult, TenantAdminEntity,
TenantConfigFieldSchema) ride as object slots — wire-identical via runtime-type
serialization, proven by the parity facts; 126 new wire-parity facts total
(Admin 26, Rules 12, Config 17, Graph 7, Metrics 16, Vulnerability 19,
Infrastructure 21 + Dictionary-key-verbatim and null-value pins); both
TypedResponseGuard baselines are now EMPTY regression guards and the untyped
OkAsync/CreatedAsync(object) overloads are gone, so a new anonymous success body
no longer compiles through the helper and fails the guard through raw
WriteAsJsonAsync; deliberately untouched: error bodies (one anonymous shape by
design), auth/me (AuthDecisionResult.Body slot shared with error bodies, tests
reflect over it), CheckMcpAccess (conditional dictionary keys), raw-byte streamers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(contract): C#→TS wire codegen — shared-manifests v2 exports the full response-type graph as generated TypeScript (batch 6 of typed-api-contract)
SharedManifestParityTests now writes schemaVersion 2: the v1 catalog sections stay
byte-compatible (sectionFieldMap/signalrMessages/sharedManifestChecks untouched),
the consumer-less sessionSummary section is gone, and a new types section carries
every wire type — roots are all IApiResponse implementers plus [WireContract]-marked
types (SlaViolatorSession, ImeVersionHistoryEntry), transitively closed by the new
WireTypeManifestBuilder over refs, collections, dictionaries, enums and
[ProjectedItems] item types (208 types). Fields honour [JsonPropertyName]/
[JsonIgnore], get-only members export readonly, a nullable slot exports optional
(WhenWritingNull omits the KEY — explicit null survives only inside collections and
dictionary values, so the nullable flag lives on nested descriptors only), field
order follows STJ's derived-before-base serialization for the one legacy inherited
wire type (LocationSessionRow : SessionSummary), and unmappable shapes (non-string
dictionary keys, foreign BCL classes) fail the build instead of degrading to
unknown. C# <summary> texts travel as JSDoc: Shared builds XML docs now (doc-quality
warnings CS1591/1573/1574/1584/0419 suppressed — crefs to foreign assemblies cannot
resolve from Shared; CS1570 malformed-XML stays an error, which surfaced a raw
0x01 control char and an unescaped ampersand in existing comments).
generate-shared-manifest-types.js emits utils/wire-types.generated.ts (interfaces +
string-union enums, 2.7k lines) next to the lean literal module, npm run
generate:manifests wraps it, the vitest freshness test pins BOTH outputs, and a new
shared-manifests-in-sync CI job (guardrails-in-sync pattern) fails on drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hand-written response types re-export the generated wire interfaces (batch 7+8 of typed-api-contract)
types/session.ts, types/enrollment.ts and types/adminConfig.ts are re-exports over
utils/wire-types.generated.ts (Session = SessionSummary alias for the 25+ barrel
importers; adminConfig keeps only the excessiveEventAutoActionMode literal-union
narrowing and the client-side OpsAlertRule view) — the generated types are never
weakened, consumers adapt: durationSeconds is now optional as on the wire (int? —
consumers guard with ?? 0, which also retires the latent NaN-minutes display on
in-progress sessions), status is the SessionStatus union (fixtures on canonical
values; the generic string-sort test keeps its own fixture type), required bools
break hand-built fixtures once → new test/factories.ts makeSession/makeEvent
(Object.assign, not spread — Partial spread re-widens and tests must force
data: undefined); the string|null lifecycle branches were already truthiness-based.
Duplicate local types deleted: geographic-performance now uses the real
LocationSessionRow wire type (incl. DO fields), GlobalSearch the generated
QuickSearchResult, sla/page.tsx the generated SlaViolatorSession (status stays a
number — C# int, rendered via statusLabels); sessionExportUtils aliases
SessionExportEvent = EnrollmentEvent and derives RuleResultCsvData via Pick, CSV
output unchanged; the three structural ReplayInputEvent clones (InstallEvent,
ScriptInputEvent, DownloadEvent) are aliases now. docs: drift-guards.md replaces the
shrink-only-OkAsync section with the typed-wire-DTO contract, index.md/log.md
updated. tsc 0, vitest 957, eslint 0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tests): SlaMetricsServiceTests anchor sessions inside the current ISO week — the 'now - 1h' fixture crossed into LAST week during the first hour of every Monday (UTC), emptying CurrentWeek and failing the three snapshot asserts (CI on Mon 2026-08-31 00:20Z; pre-existing Monday-morning time bomb, unrelated to the DTO migration); the anchor is now clamped to the week's Monday 00:00, proven green inside the same failure window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The extra hint line under the search field made the header say the same
thing three times (subheadline, placeholder, hint). The Cloud PC guidance
now rides on the input's title attribute — the plain browser tooltip on
hover, no cursor change — and the subheadline no longer repeats the input
format. Same guidance is documented in the customer docs (Windows 365
Cloud PCs, Progress Portal).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The placeholder carried the Cloud PC instruction ("Cloud PC: the name shown
in the Windows App") and was clipped by the input width, so end users saw a
truncated sentence they could not read. Placeholder is now short ("Serial
number or device name..."), the Cloud PC guidance moved to a permanent hint
line below the field (wired via aria-describedby), the subheadline mentions
the device name as an accepted input, and the button no longer wraps on
narrow viewports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shutdown run allowed every logged-in user (explorer.exe owner) dynamically, so a backdoor account created after the startup scan and signed in before enrollment completed was scored 0. The WMI inventory is now read first and a logged-in name that is a local account is rejected for the account and the profile check (reported as logged_in_local_accounts); only Entra/domain identities — the only ones seen in real sessions — remain dynamically allowed for their profile folder.
Built-ins are recognised by RID 500/501/503/504 on the machine SID in addition to their English names: since disabled accounts are inventoried (88dfd331), localized Gast/Invité/Administrateur would otherwise flag every non-English device. kioskUser0 (Assigned Access autologon) joins the built-in list; the profile check skips junctions and $<32 hex> folders (cannot be an account name, pure payload noise). ANALYZE-ID-002 1.3.0 explanation mentions the new field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The path guards, the enumeration's reparse-point skip and the per-file attribute check all judged path strings, while the FileStream feeding the ZIP opened that string later (after earlier files were compressed). Under any recursive source folder a local user can write to, a subdirectory turned into a junction in that window made SYSTEM copy the junction target into the tenant-uploaded package.
PinnedSourceFolder holds each section folder open for the whole section (final path must equal the validated path, 8.3-expanded; FILE_LIST_DIRECTORY without delete share, so NTFS refuses renaming it or any ancestor) and opens each candidate with FILE_FLAG_OPEN_REPARSE_POINT, requiring the handle's final path to be exactly <canonical folder>\<relative path>; the archive is fed from that same handle. Manifest: FOLDER REJECTED (...) / SKIPPED (resolved outside validated folder) / FAILED (open). The race is pinned by a test that swaps the directory from the BeforeSourceFileOpen seam; on the previous code the same test copies the junction target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A crafted /sessions?id=x%2F..%2F..%2Fglobal%2Fy%23 deep link let the victim's browser resolve
dot-segments into a different API route and truncate builder suffixes via '#', turning UI
actions into attacker-chosen authenticated same-origin requests. Hydration now also requires
the returned sessionId to match the requested one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tenant hardware whitelist is stored as a CSV and split on ',' at
enforcement time. Manufacturer/model strings in the Rejected-Hardware
insights come from unauthenticated distress signals, so a value like
"Dell Inc.,*" added via "+ Mfr" appended a standalone "*" and silently
turned the manufacturer gate into allow-all.
- Web: shared hardwareWhitelist helper; "+ Mfr"/"+ Model" and the manual
editor neutralize ',' to the single-char wildcard '?' so a value can
never split the list.
- Backend: TenantConfigValidation.ValidateHardwareWhitelist in ValidateModel
(PUT + patch) rejects blank items, control chars, over-long/too many entries.
- Shared: ParseWhitelist trims entries so enforcement matches the editor view.
- Tests: 8 xUnit + 5 vitest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubmitSessionReport built the report blob name from the raw route sessionId. Route values are
percent-decoded, so an encoded '/'+'..' reached Azure.Storage.Blobs, which keeps separators, and
System.Uri collapsed the dot segments into a write outside the session-reports container.
- SubmitSessionReport / SubmitDiagFilesReport: 400 unless sessionId/tenantId are GUIDs
- SessionReportService: EnsureValidGuid at entry of both submit paths
- New Security/BlobNameGuard (flat-name invariant) applied in SessionReportService,
SessionReportDiagnosticsArchiveCopier (pre-check + upload) and the GA download function
- Tests: BlobNameGuardTests, copier rejects non-flat destination without storage calls
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult
(GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for
the whole suspension window and during an offboarding wipe. DecideAsync now denies 403
TenantSuspended when the caller's home tenant or the request's target tenant is currently
disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the
tenant; device/anonymous routes keep their SecurityValidator gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Welcome/farewell mails rendered DomainName unencoded while the full-model
config PUT persisted it verbatim, letting a tenant admin ship attacker HTML
in a signed product mail to an arbitrary recipient. Encode at Render() and
the built-in builders, preserve DomainName from the stored row on PUT for
every caller, and only seed a strict DNS host name at first login.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EffectRunner posts ClassifierVerdictIssued from inside DecisionStepProcessor.ApplyStep,
i.e. on the SignalIngress worker thread. With the bounded channel (256) full, Post's
back-pressure slow path blocked the channel's only consumer as a producer: nothing could
ever drain, every collector Post blocked behind it, and the whole decision/monitoring
pipeline froze silently until the max-lifetime watchdog.
Post now detects a call from the worker thread itself and routes the item to a
worker-local follow-up queue that WorkerLoop drains (iteratively, ahead of the channel)
right after the current item. Same processing path — ordinals, SignalLog, emit,
reduce, ApplyStep — just never blocking. Worker posts are accepted during the Stop
drain too, since the verdict belongs to the step being finished. ReentrantPostCount
exposed for observability/tests.
Tests: full channel + worker post releases a blocked foreign producer and processes the
verdict before the next channel item (fails against the previous code by hanging);
follow-up chain drained in order without recursion; verdict posted during Stop drain is
not lost. Docs: agent/decision-engine.md, agent/overview.md, log.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied
session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune
device id from the CN, or bootstrap code + serial) in primary-only Owner* columns.
SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it;
SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a
throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage.
Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch
without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was
absent. That header is client-writable, and certificate content alone carries no proof of private-key
possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable,
but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token
passed the function gate (Headers.Contains) while the validator treated it as no token and fell
through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29).
- Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths.
- IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired
BootstrapSessionService => 401 before the certificate stage.
- GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it.
- Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CollectLogsQuickConfigEnabled fires when a tenant admin switches on Hosted
diagnostics upload through the session Collect Logs quick-config dialog
(PUT ?intent=collect-logs -> write source portal-collect-logs); every other
enable/disable path emits DiagnosticsUploadEnabled/Disabled (PUT, PATCH,
revert, MCP). SessionActionQueued records portal-queued server actions.
DiagnosticsUploadConfigChange is the single definition behind the
diagnosticsUploadConfigured feature flag and the flip detection. Event types
dual-registered in the OpsAlertRules catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rotate_config only stored the fetched config in RemoteConfigService and never
merged it into the running AgentConfiguration, so the portal quick-config flow
(enable diagnostics upload, then collect) ran the diagnostics gate against the
stale mode=Off and failed with a generic diagnostics_upload_failed 4 ms later.
The action now runs the same ApplyFetchedConfig path as the startup fetch and
reports config_fetch_failed when the live fetch fell back to cache/defaults.
Skipped uploads return a DiagnosticsUploadResult.Skipped with the gate name
(diagnostics_skipped_mode_off / _not_configured / _onfailure_success) instead
of null, so server_action_failed names the actual reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.
- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
(bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.
- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
(no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md
Legacy role rows are inert until their binding is seeded - seed before deploying the backend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author-controlled relatedDocs[].url was rendered verbatim as <a href> on the session
detail, diagnosis and analyze-rules pages and never validated server-side, so a
Tenant Admin could store a javascript: URL that executes in the portal origin for
any viewer — including cross-tenant Global/MSP admins.
- Web: safeHttpUrl() guard (absolute http/https only) at all four render sites;
anything else renders as plain text.
- Backend: ValidateRelatedDocs on Create/Update — blank URLs dropped, non-http(s)
URLs rejected with 400.
- Tests on both sides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Microsoft serves IME from several versionless hosts carrying different rings at once
(secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the
canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools
each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no
msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later
sighting with a version-authoritative CSP URL re-queues an unarchived version at most
once per 24 h (MsiArchiveUpdatedAt + Queued stamp).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS
re-score ran on the nightly timer alone. Both walks move out of the timer into
VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the
nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for
a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the
minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the
page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST
vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a
manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new
AdminConfiguration fields.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.
NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.
CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0
(zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras
through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode,
server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with
enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless
fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare
listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results
(5 min) and resource reads (60 min) — the catalog is role-dependent, so never public.
Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID
Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any
request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache).
The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR
client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle).
Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity;
cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths.
Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A redeploy at an unchanged code sha (the documented way to refresh the
baked-in search_docs corpus) built an image with the identical tag;
az containerapp update saw the same image reference and created no
revision, so the verify step timed out against the old build (run
33215583943). Tag = sha-run_number; build commit/version stay separate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cloud PC users know the device name the Windows App shows, and the lookup
already accepts device names, so there is no reason to exclude them. The
monitored enrollment starts at first connect in Account Setup (v1 rail,
phases 4 -> 5 -> 7; device phases 1-3 run headless and are never declared),
so the Cloud PC layout lists only the steps the user can actually watch:
First sign-in / Account setup / Installing apps / Finalizing setup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The end-user page hardcoded the seven Autopilot v1 ESP phases and gated its live
app panel on currentPhase 3/5 — phases a Device Preparation session never
declares — so DevPrep users saw 0 % and no app progress until Finalizing.
Steps now derive from the shared resolvePhaseLayout (v2 layout, skip-user
layout), the v2 app step is promoted from app-event evidence, and the status
vocabulary maps AwaitingUser / parked pre-provisioned devices to a waiting
state and Incomplete to neutral — only Failed stays red. Cloud PCs are named
as unsupported.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case
was a relaunched agent re-tailing the IME log and uploading one burst holding
most of the session's IME samples with a continuum of ages whose median hit the
15-minute grid by chance. The tripwire now also requires >= 80 % of the IME
deltas (relative to the other side's median) to individually sit within 2 min of
some grid multiple; era-mixing regressions still fire. Fraction is carried in the
ops event message and details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Microsoft released Windows Autopilot device association on 2026-08-25. The
DevPrep association lookup leaves shadow mode and becomes an accepting method
of the device-validation gate (after Autopilot and Corporate Identifier, before
the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The
toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields
dropped, settings toggle shown to every tenant admin under Device Preparation
with the shared consent rule. Landing hero carries a release pill linking to
the docs; dashboard tip added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two analyze rules born from the pilatus-aircraft diagnosis (2026-08-27):
- ANALYZE-APP-017 "App Install Blocked by Another Installer (0x80070652)":
fires on app_install_failed with hresultFromWin32 -2147023278
(ERROR_INSTALL_ALREADY_RUNNING); confidence boosts for MSI exit code
1618 and for an ESP failure settling with the same code. The 08-26
burst failed 16 sessions deterministically at ~21 min (IME's fixed
msiexec-busy retry budget) right after a preceding installer left the
Windows Installer mutex held - previously only generic
"Enrollment Failed" fired, naming no cause.
- ANALYZE-NET-001 "Diagnostics Upload Blocked by Web Security Gateway":
fires on diagnostics_upload_failed answered by an HTML block page
carrying a recognizable security-product marker (McAfee/Skyhigh,
Zscaler, Netskope, ...). OnFailure diagnostics are lost exactly on the
sessions that need them; plain HTML errors without a vendor marker
(stale/stopped endpoint) stay excluded on purpose.
Both validated via MCP validate_rule (0 errors) and dry-run with
test_analyze_rule: fire on the burst session (confidence 100 / 90),
silent on a timeout-shaped failure and on a succeeded session that
carried unrelated app_install_failed events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:
- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
real-user desktop + zero app_install_failed reconciles to Succeeded
"completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
2.6% - the succeeded background, not the ~29% failure level - and the
sampled desktop half had clean app records. No desktop or any failed app
stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
(enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
the stream: all five observed maxlife:r5_awaiting parks expired unhealed
while live-agent parks upgraded 7/26. The maxlife ingest appends its
trigger event to the read stream so the fact is never missed; late
telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
noise), per-path share regression now needs >=5 baseline hits (new or
renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
into the silence/classifier-verdict group sums on both sides of the
2026-08-23 instrumentation cutover - dissolving the lift-124 silence
artifact. Episodes below the fire floors clear themselves.
Backend only; the new path appears in the calibration matrix dynamically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A delivery log names only the recipient, never the tenant the welcome mail was
for. The tenant console's search now matches the notification and contact
address as well as domain and tenant id; the quoted exact mode is unchanged, and
no other address is searchable (an admin UPN never receives platform mail and
would only widen the PII surface of the list).
The addresses live in PreviewWhitelist, not on the tenant config, so a new
GA/Reader-only GET /api/preview/notification-emails returns them as one map --
a single cross-partition scan instead of a point read per tenant. Both sides
join on the lowercased tenant id (address rows carry the JWT tid, a config
PartitionKey's casing is not guaranteed) and matching is case-insensitive,
because stored addresses keep the casing the user typed.
The map is best-effort in the portal: a failed read costs only the
search-by-address, never the tenant list. Addresses stay out of the list rows --
they are a search key, and remain visible in the tenant editor.
Match rules moved out of the component into tenantSearch.ts with unit pins, and
a catalog test pins that the plural route does not resolve to the per-tenant
entry one character away (which would demand a tenantId the route does not
carry, and hand a delegated caller a rescue path into the whole map).
Deploy: backend before web.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The activation page held the only address field in the product, and its redirect
effect fired on the first poll after auto-approve — roughly 60 seconds after
signup. Anyone who had not typed by then lost the field, no notification-email row
was ever written, and TrySendWelcomeEmailAsync deferred a mail that nothing would
send later. Nine of twenty-one August signups got a welcome mail; the last one
went out on the 21st.
It stayed hidden because every "not sent" branch logged at Information, and worker
application logs never reach Application Insights below Warning.
- Activation page holds the redirect until the address is saved or deliberately
skipped, and switches to an "Activated" state while it waits. Visitors who
arrive at an already-activated tenant are passed straight through as before.
- Recipient resolution falls back from the activation-page address to the tenant
contact address. The signup admin UPN is deliberately not a fallback: those
accounts often have no mailbox and the bounces cost sender reputation.
- WelcomeEmailSent / WelcomeEmailSkipped / WelcomeEmailFailed ops events make
every outcome visible, dual-registered in OPS_EVENT_TYPES.
Three defects in the same path, found while fixing it:
- The sent-marker was consumed before the send and never released on refusal, so
one provider hiccup suppressed a tenant's welcome mail permanently. It now
records delivery, not an attempt.
- A missing provider key logged at Debug, hiding the one condition that stops
every mail at once. Now Warning.
- The Global Admin resend endpoint reported success regardless of what the
provider answered. It is the manual fallback for exactly this failure, so it now
returns 502 and leaves the marker free when the message is refused.
The welcome mail is also no longer fire-and-forget: a discarded Task can be torn
down when the invocation ends, and its result is what the ops event reports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi
signal" stat that simply vanished — on a location-gated device that reads as a
product defect rather than the Windows 24H2 gate that caused it.
No agent change was needed: buildSegments already merges every wifi_signal_info
into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in
with the sample that was already being read.
- NetSegment.dataLimitedReason, filled from the sample.
- Segments that never got a sample of their own (a mid-session reconnect emits
no wifi_signal_info) inherit it: the gate is a device-level setting and cannot
flip during an enrollment. A segment WITH a real reading is never overwritten
— pinned by a test.
- Tooltip: "Signal: unavailable — Location services off on this device" where
the percentage would be.
- Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping
the row.
network_state_change is deliberately left alone. That event has never carried
signalPercent or radioType — NetworkChangeDetector collects them into its
snapshot for change detection, but only before_/after_wifiSsid reaches the
payload — so there is no absent field there for a reason to explain, and the
SSID it does carry is unaffected by the gate. Adding it would ship a field
nothing reads.
Verified: web tsc + eslint (0 warnings) + vitest 893/893.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since 058248d9 the SSID survives the gate via the WinRT tier —
but signal, radio and channel silently vanish with no explanation anywhere.
When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now
carries wifiDataLimitedReason = "location_services_off" and the event message
gains "— signal unavailable, Location services off", so the reason reads straight
off the timeline. The portal's WiFi card matches that literal and names the
Intune setting that restores the full payload.
The claim is evidence-only: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting
we did not observe. IsLocationGateDenied is the single predicate, pinned at its
boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as
the published agent/portal/MCP contract.
New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can
be sized before anyone changes a policy:
search_sessions(deviceProperties: {
"wifi_signal_info.wifiDataLimitedReason": "location_services_off" })
Backend is untouched — the event payload is opaque DataJson with no projection.
network_state_change and the network timeline deliberately do not carry the
reason yet; that is a second payload plus web change for no extra insight.
Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings)
+ vitest 889/889, MCP tsc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A completed scan is a finding about the software inventory, not an
enrollment failure. Mapping the CVE risk onto Error/Critical made the
report read as a failed enrollment: the diagnosis page collected it as
failure evidence, MCP search lifted it on failure-intent queries, and the
timeline's Error filter filled up with clean sessions. critical/high now
map to Warning, everything else to Info; the CVE severity itself stays in
scan_summary and on the report panel's own scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments
(build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01).
Not a parser or language problem: every event that exists carries a full SSID,
so the failure is binary at the reader level.
Since 24H2 Windows gates WlanQueryInterface with
wlan_intf_opcode_current_connection — the only opcode we use — behind
precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt
only fires for a process in the user's context outside System32, so the agent
(SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it
resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none
— while the interactive user on the same machine reads the SSID fine via the
per-user NonPackaged value. That is why it tracks the image's location setting,
not hardware, driver or language.
netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its
multi-language labels never covered the case they were built for; more labels
cannot fix it.
- New WinRtWifiSsidReader: Microsoft's documented replacement
WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via
the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same
mechanism OobeStateReader already uses from the SYSTEM context.
- New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both
collectors had duplicated.
- The step diagnostics added in 2026-07 were logged at Debug while the default
level is Info, so they never reached a field log. The all-tiers-failed case is
now a Warning that decodes rc=5 into the location-gate explanation.
Signal, PHY and channel have no ungated source and stay null on tier 2;
deriving a percentage from GetSignalBars would fabricate precision.
Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured
live (532/604), and the full WinRT chain resolves and returns the SSID under
net48.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline search box now tokenizes the query instead of treating it as a
single substring: terms are AND-ed and a leading minus hides its matches
(e.g. -app_install_progress), following the convention every other search box
uses. Quoting keeps a term literal, so an exit code like "-1" stays searchable.
The negation is decided at a token's first character, before a quote is
consumed, and terms match the same fields as the positive search (event type,
message, source) joined by a newline so a term can never match across a field
boundary. The event counter follows the search and an active exclusion is
named in a pill next to the severity filters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Intune roots the chain is pinned to are shared by every Intune tenant, so
a valid certificate only ever proved "issued by Intune to someone". Tenant
scoping rested on the device serial, which is printed on the chassis. A
Mismatch now returns 403.
Switched on against measured data, not a hunch: 35,684 requests across 32
tenants since the shadow telemetry landed, every one a Match, with the
Warning channel independently verified to be carrying other traffic so the
zero could not be a telemetry artefact.
Only Mismatch blocks, and the rule lives in one function so "what rejects a
request" has a single answer. ExtensionMissing, Unparseable and
RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only
covers devices active in the window, so an older certificate or a
sovereign-cloud CA could still lack the extension, and rejecting on absence
would lock those devices out for something outside their control.
ExtensionMissing is the candidate for later tightening and has its own test
pinning the tolerance, so removing it must be a decision.
Evaluation fails open on its own exception — a defect on our side is not
evidence of a cross-tenant certificate. The 403 body does not echo which
tenant the certificate belongs to; that pair goes to the rejection log.
Device binding (stage 3) is untouched: still shadow, still Global-Admin-only.
Backend 4638/4638, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.
IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.
Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.
ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.
Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same gate the cert-device-binding shadow got in 35a3ed0c, applied to its
sibling. Measured on the live enrollment: 263 identical shadow lines for one
device off a handful of Graph calls, because the cache answered nearly every
request and the log sat outside it.
DeviceAssociationResult.AsCacheHit() copies rather than marking the shared
cached instance, so the first real lookup is never mistaken for a cache hit
and dropped. Transient outcomes are not cached and keep logging every time.
Backend 4633/4633, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the first live enrollment: 136 agent requests produced exactly
ONE Graph call, because the 30-minute positive cache absorbed the rest. So
the check is not a throttling risk (the per-request Autopilot serial
validator made 25 calls in the same window) and the positive TTL needs no
change — raising it would only delay noticing a wiped or removed device.
What the 136 requests did cost was 136 log lines all saying the same thing.
Gate the shadow log on ServedFromCache so it emits one line per real lookup.
Cached repeats carry no new information: the reported age just counts up
mechanically from the same enrolledDateTime. Outcomes that are never cached
(Transient, NoDeviceIdInCert) keep logging every time, which is what an
anomaly warrants.
AsCacheHit() copies rather than marking the shared cached instance, so the
first real lookup is never mistaken for a cache hit and dropped.
Backend 4631/4631, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.
IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.
Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.
The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.
Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).
Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-deploy verification of 249e39bc found zero AgentCertTenantBinding rows
across 32k agent requests. Cause: the Match outcome was emitted with
LogInformation, and worker-side ILogger below Warning never reaches App
Insights (provider default rule is Warning+, host forwarding of worker logs
is off). The bulk outcome was therefore invisible, which is indistinguishable
from the code never running — the shadow telemetry had no denominator.
Stamp every outcome onto the request row instead (FunctionContext.Items ->
RequestTelemetryMiddleware -> CertTenantBinding dimension). That row already
exists per request and is unsampled, so the denominator costs no extra
telemetry. Non-Match outcomes keep their Warning trace with full context.
FromCache existed only to sample the dead Match line and is removed with it.
Backend 4606/4606, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A chain-valid Intune MDM certificate only proves "issued to some tenant" —
the pinned roots are shared by every Intune tenant. Tenant scoping rested
entirely on the device serial in the downstream validators, which is not a
secret. Bind the tenant stamped into the auth cert to the requested tenant.
The Entra tenant id sits in OID 1.2.840.113556.5.14 of the cert the agent
already presents for mTLS, so this needs no Graph call, no consent and no
agent release. Not 5.6 (Intune Account id, a different GUID) and not 5.4
(device id, stored as 16 bare bytes) — the decoder handles both encodings
and now lives once in Shared, used by agent and backend.
Stage 1 observes only: ObserveCertTenantBinding returns void and swallows
its own exceptions, so it cannot fail an otherwise valid request. Match is
logged only on cert-cache miss, keeping the denominator without per-request
volume. Grep marker for stage 2: CERT-TENANT-BINDING-SHADOW.
Backend 4604/4604, Agent V2.Core 2519/2519, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intune's MDM device cert stores the DeviceId in OID 1.2.840.113556.5.4 as a
bare 16-byte GUID with no ASN.1 wrapper, so the strict OCTET-STRING parser
bailed on the tag check and returned null. Verified against a live Intune
device cert: 5.4 is 16 raw bytes, while 5.6 / 5.11 / 5.14 carry the usual
04 10 wrapper.
- ParseGuidFromAsn1OctetString -> ParseGuidFromExtension (the old name no
longer describes what it parses); both encodings now handled, with the
shared LE-GUID construction pulled into ToGuid(). The forms cannot
collide: a wrapped 16-byte GUID is always >= 18 bytes.
- Document the Intune MDM cert OID arc on the class: 5.4 DeviceId,
5.6 Intune AccountId (NOT the Entra tenant), 5.14 Entra TenantId,
5.11 undocumented GUID, 5.15/.16/.17/.18/.19/.23 INTEGER enums.
- 13 tests pinning both encodings plus the malformed-input rejections,
including a bare GUID whose first byte is the 0x04 OCTET STRING tag.
No behaviour change for the MS-Organization-Access probe the agent uses;
TryGetTenantIdFromCert is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The built-in default config shipped empty ImeLogPatterns/GatherRules lists, so a
session that got neither a live fetch nor a cached config ran with the entire IME
tracking pipeline dead — no app installs, no script results, no health-script
compliance, no IME ESP-phase signals — although the agent binary carries all 37
action handlers. The scalar knobs never drift (shared AgentConfigResponse property
initializers are also the backend's fallbacks), but the catalogs cannot come from
initializers.
BuiltInConfigCatalog embeds the SAME CI-combined rules/dist artifacts the backend
embeds and serves (ime-log-patterns.json, gather-rules.json) and feeds the enabled
entries into CreateDefaultConfig. Single source by construction: each agent release
snapshots the catalog state of its own build, so the offline fallback can never
drift further than the running agent version itself. A live fetch (startup,
post-registration recovery, rotate_config) always wins. Fail-soft: a load error
degrades to the previous empty-list behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (tenant aebdce78 audit 2026-08-24, session ba828038): on boot-time
relaunches the agent starts before the Wi-Fi link is up. The Phase 4 config fetch
had no network gate (98f8114d only guarded Phase 6 registration), burned its
10/30/60s retry budget into a dead link and stranded the whole session on built-in
defaults (ConfigVersion=0) — up to ~50% of that tenant's sessions, with no
self-heal short of a manual rotate_config action. Not a version regression: rates
varied wildly per day within the same agent build; the failed requests never
reached the backend.
Two complementary layers:
- RemoteConfigService gates the initial fetch on the shared NetworkLinkWait poll
(60s cap — longer than registration's 15s because the fetch runs first), so the
retry budget starts on a live link. rotate_config stays ungated (latency-bound).
- Phase 6.5: after registration proves the backend reachable, TryRecoverConfigAsync
refetches once and AgentRuntimeConfig.TryRecoverAfterRegistration re-applies the
live config (same merge/integrity pipeline as Phase 4). LastFetch* keeps the
original failure record; remote_config_fetch_failed reports it with a recovered
flag at Info severity, agent_started flags remoteConfigRecovered.
Deliberately not re-run in recovery: kill-signal check (telemetry-channel control
signals cover post-registration kills) and endpoint migration (next start).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-verified after 0d5e2572 deploy: the worker filter removes every 404/412/409
and worker SignalR row, but the SignalR output-binding calls are emitted by the
host process (SDK rdddsc) and only reachable at ingestion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One CVE commonly appears under many findings of the same report (e.g. one
RD CVE across 14 installed RD components). Each occurrence produced its own
concurrent upsert on the same PK/RK, so the stored SoftwareName/OverallRisk
was race-dependent. Collapse to one entity per CVE (highest CVSS wins,
IsKev OR-ed, OverallRisk max) and write with Parallel.ForEachAsync(8) and
per-entity fail-soft logging instead of Task.WhenAll + one outer catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Timer-thread Post() appended to a bare List while the test polled it in a
SpinWait foreach -> 'Collection was modified' (run 32644425162, no assert
text). Posted now returns a locked snapshot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both sliding-window limiters re-Set the cache entry without its eviction callback.
The replaced entry fired the callback asynchronously, removing the per-key lock while
a new one was already handed out, so two threads could mutate the same List under
different locks; the replacement entry never cleaned its lock, leaking one object per
key for the process lifetime.
The history now lives in a RequestBucket together with its own lock: same list ⇒ same
lock, no callback, no dictionary. Bucket creation is serialized (GetOrCreate is not
atomic) behind a lock-free TryGetValue fast path. The redundant re-Set is gone — the
lookup already refreshes the sliding expiration.
Concurrency tests (16 threads × 500 requests, one key) fail 3/3 on the old code and
pass on the new one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StartupTelemetryService emits BackendStartupMs (process start ->
ApplicationStarted) and BackendTableInitMs as App Insights custom
metrics with version/commit/tableInitFullPass/instance dimensions;
TableStorageService exposes LastInitialization. MCP server writes one
{"type":"boot","readyMs"} stderr line per start next to the
tool_call lines so cold-start time is queryable as a trend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cold start point-reads a SHA-256 over TableNames.All stored in
AdminConfiguration/SchemaSentinel/tables instead of issuing 66
CreateTableIfNotExists calls. Missing/stale sentinel runs the full
idempotent pass once and rewrites it. SessionsIndex startup backfill
only after a full pass, behind a conditional-insert claim (one instance
under scale-out); IsSessionIndexEmptyAsync no longer treats errors as
empty. Daily maintenance runs IStorageInitializer.EnsureAllAsync to
repair out-of-band table deletions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop per-row window7/baseline28.sessions (duplicated trend denominators), make
reEnrollRatePct/lift always present with null = withheld, add minSharePct/top
tail trimming that never drops override-carrying rows and reports what was
omitted. Backend wire shape and admin page untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First production pass alerted on agent:whiteglove_pending (two tenants in a
WhiteGlove rollout week) and agent:complete_soft (a Continue-Anyway tenant) —
customer workflow mix, not classifier drift. agent:* and register:* are now
ineligible for share_regression (still in the matrix and the silence group);
episodes opened by the broader gate re-arm on the next pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cross-tenant StartedAt-only date-range query is a full-table drain in
Table Storage; time attribution, device journeys, verdict calibration and the
calibration radar's tenant discovery each ran their own — four identical reads
per 2h tick. RunAllAsync / RunManualAsync now load the window once through
GetMaintenanceWindowSessionsAsync (MaintenanceSweepSessionProjection: the union
of the sweeps' columns, drops FailureSnapshotJson/geo/OS/diagnostics) and the
sweeps slice it by StartedAt. MaintenanceSweepProjectionEquivalenceTests pins
that every consumed field survives the projection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies the rule-regression statistics (7d vs 28d, lift >=2, Wilson-separated,
small-n gates) to the verdict-calibration rows: per-path share regression,
sweep+maxlife silence-share regression (agent liveness signal) and an
absolute r6 evidence-gap gate. Episodes live in the notification tracker
(verdictcalibration|kind|path|status), fire one VerdictCalibrationDrift ops
event (operator-only, no tenant bell), re-arm under 1.5x / 15%, and surface as
alerts[] + a Drift pill in the calibration matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Daily (tenant x StartedAt date) buckets per verdict path: count, derived
attribution, 7-day re-enrollment proxy (eligible/re-enrolled), and the
correction stream attributed to the PRIOR path (admin / late completion /
other). Written by the 2h maintenance sweep after the device-journey pass,
180d retention, regenerable. Read via GET global/metrics/verdict-calibration
(GlobalReadOrAdmin, 7d-vs-28d share trend, rates withheld below n=20),
the get_verdict_calibration MCP tool, and the admin metrics section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every status writer now declares the code path that produced the status
(VerdictPaths vocabulary, stamped for all statuses — unlike FailureSource
which is Failed-only), the timeout classifier returns its rule id, and a
write that overrides a prior verdict preserves it as PriorStatus/
PriorVerdictPath (admin mark, late completion, retro). Pre-instrumentation
rows derive a path read-side (VerdictPathDerivation, legacy:* when
ambiguous). Foundation for the verdict calibration aggregate + radar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kiosk tenant audit 2026-08-23: 836 of ~970 self-deploying sessions in one week
were parked AwaitingUser ("Account Setup 0/5") and graduated to Incomplete 51h
later although the devices were finished — the profile has no user phase, so the
classifier's rule 5 was asking for evidence that can never arrive.
- EnrollmentTimeoutClassifier: new rule 1c IsSelfDeployingProvisioned (profile flag +
Device Setup all-succeeded + no explicit failure) => Succeeded reconcile with an
honest reason; isSelfDeployingProfile passed by every caller (sweep stage 1+2,
max-lifetime verdict, late-telemetry heal, legacy retro mode).
- MaintenanceService stage 1: TryReconcileSelfDeployingAsync gate before the Stalled
marker (mirrors the WhiteGlove Part-2 gate), audit counter SessionsSelfDeployingReconciled.
- LegacyReclassificationService: new admin mode self_deploying_silent (heal-only,
dry-run by default) + GetSelfDeployingSilentSessionsAsync for the existing backlog.
- Agent SessionRegistrationHelper: wait up to 15s for a network link and retry 6x
(~62s) instead of 5x (~30s) so the BootTrigger relaunch on Wi-Fi no longer exits 7
before the link is back (the mechanism that left these sessions silent).
- Tests: 6 classifier cases (gate, within/past grace, user-driven guard, failure,
pre-Device-Setup), 2 registration tests; docs/backend/silent-session-classification.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Below `sm` the path owns the first line(s) and wraps in full (flex-wrap,
basis-full, break-all) with badges and controls flowing onto the next line —
a path truncated to "C:\…" is useless on a phone and a tooltip cannot be
hovered there. From `sm` up the row stays a single truncated line. The
"N file types" pill is shown on every width again now that the badge line
has room.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings section loses both pills; the vulnerability report footer keeps the
best-effort CVE matching note without the Experimental prefix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The diagnostics package's built-in collection is now ONE catalog in Shared
(DiagnosticsBuiltInSections.All): the agent iterates it when building the ZIP and
the backend serves the same list to the portal (GET /api/diagnostics/paths,
MemberRead), so what administrators see is exactly what the agent collects.
Agent
- New conditional sections: RealmJoin client/package/Chocolatey/per-user logs
behind the tenant's RealmJoin Watcher toggle (mirrored onto
AgentConfiguration.EnableRealmJoinWatcher by RemoteConfigMerger) and the Device
Preparation bootstrapper event log behind the deterministic WDP marker (probe
once per build, fail toward Classic). ZIP layout mirrors the disk
(RealmJoinLogs/Windows|Choco|User). Every skip is a package-manifest line.
- Built-ins bypass DiagnosticsPathGuards by design (reviewed code); caps,
reparse-point skip and evtx export still apply. No ConfigVersion bump.
Backend
- GET diagnostics/paths (MemberRead, JWT-scoped) with the catalog + global paths.
- MergeDiagnosticsLogPaths: global ∪ tenant dedup (trimmed, case-insensitive) —
duplicates used to produce duplicate ZIP entry names.
Web
- Collapsed read-only built-in list (RealmJoin on/off in the tenant header,
neutral in the Global-Admin view), one-line DiagnosticsPathRow for every entry,
add-row above the lists; tenant context no longer fetches /api/global/config.
Tests: agent RealmJoin on/off, no user session, bootstrapper evtx, probe failure,
merger mirror; backend catalog invariants, payload shape, dedup, policy; web
builtInSectionDisplay. Docs: docs/agent/diagnostics-package.md (+index/log).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both toggles gate collector hosts / device behavior, not IAgentAnalyzers, and
tenants looked for them under Agent Settings. RealmJoin Watcher now sits in the
Agent Collectors card, Keep Awake During User-ESP in Agent Parameters; OOBE
console detection stays with the security analyzers. Section ownership in
SECTION_FIELD_MAP and the per-section reset handlers follow, the Agent Settings
Save/Reset bar moves below both cards it saves, and the Analyzers sidebar
description names all four checks. Wire contract (AnalyzerConfiguration) and
agent are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Back to the 838da4cc header: one facet gradient, three-row brand mark, 113px
band, clip-path edge for clients that support it. Clients without gradient
support get a flat band instead of band-coloured blocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Row 1 keeps the single facet gradient and adds a top-right cell with its own
dark/light split (the corner facet); row 2 draws the slanted bottom edge as a
green/white gradient in three band-coloured cells. Pixel-measured against the
reference render: band boundaries identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Outlook web/iOS render exactly one linear-gradient with hex colours and % stops
(multiple layers / rgba stops are dropped), and dark mode inverts cell
backgrounds but not border colours — the white cell in the mark became a black
bar. Facets are now one gradient (top-right light corner dropped), the mark's
middle row is a white-bordered div.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Triangle / white cell + triangle / triangle, no overlays, no transparent td
borders; matches the reference mark in a side-by-side zoom render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified side-by-side against the reference render (scratch header-render.html):
three layered half-plane gradients reproduce the light/dark facets, the mark is
a CSS trapezoid plus two green wedges for the slanted right edge and the
descending bottom edge. Still no images in the mail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
112deg stripe gradient, 13/20/2 border-drawn mark, clip-path edge — the version
the user signed off on; later 'improvements' drifted from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against the original SVG mark in a Chromium render; the gradient keeps
the exact stop count that rendered in Outlook earlier.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- EmailTemplateService: override-or-built-in resolution per send (PreviewConfig
table, partition EmailTemplates, 5-min cache, {{domainName}} placeholder,
30k limit); send path fail-soft, admin read path strict
- GA endpoints global/email-templates/{kind} (GET/PUT/DELETE) and /test, which
mails the effective template or an unsaved draft to the caller's tenant
contact address
- portal: Settings → Email Templates with preview frame, test send, HTML editor
- mail header: brand mark drawn with CSS borders, no images left in the mails
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CSS border trapezoid did not reproduce the mark's shape across clients; the
mark is brand identity, so it is the one image in the mail (empty alt). Stripes
now run top-left to bottom-right, thin/wide/thin as in the reference banner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Progressive enhancement: solid bgcolor band + text wordmark everywhere; the
brand mark is a CSS border trapezoid; diagonal stripes (linear-gradient) and the
slanted bottom edge (clip-path) render where supported and degrade to a flat
band elsewhere. No remote images left in either mail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Solid bgcolor band with the wordmark as real text; only the 36px brand mark is
an image (empty alt). Replaces the full-width header PNG, which collapsed to a
bare green bar on clients that block remote images (iOS Mail).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- header is a hosted PNG (www/email/header.png, rendered from the brand mark +
wordmark) on a solid bgcolor band; mail clients drop CSS gradients, which left
the old header white
- welcome mail links "sign in" to the portal, active-development banner removed
- em dashes replaced with en dashes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- EmailService + IEmailService: Mandrill messages/send via typed HttpClient with the
Notification resilience policy; provider confined to one transport method and the
Email:* settings (ApiKey, Endpoint, FromAddress, FromName). Resend NuGet dropped.
- open/click tracking explicitly off; sent/queued/scheduled = ok, rejected/invalid = warn
- callers (TenantApprovalService, PreviewWhitelistFunction) depend on IEmailService
- EmailServiceTests pin wire format, result semantics and fail-soft contract
- privacy page + OKF welcome-mail doc updated for the new sub-processor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Discord (provider 30) renders alerts as a single embed with Discord's
field/length caps; actions become markdown links since webhooks cannot
post buttons. Generic JSON channels gain an optional per-channel signing
secret: requests carry X-AutopilotMonitor-Timestamp and
X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"),
computed over the exact posted JSON with same-named custom headers
stripped first. Secret is redacted for readers and restored by channel
id on save; validation enforces 16-128 printable ASCII characters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retention was the only enforcement point that destroyed state on a
Pro -> Community downgrade: the 12-hourly sweep clamps to the Community
cap at read time and would immediately start hard-deleting the
90-365-day band (trial expiry identical, silently).
New policy: for 30 days after losing Pro
(FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap
keeps applying. Anchor model: ProDowngradedUtc (backend-only field,
patch-denied + revert-protected) stamped/cleared by the plan endpoint's
new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers
explicitly ended trials; a planTier downgrade under an active trial
deliberately does not stamp); trial expiry anchors read-time via
TrialExpiresUtc. days<=0 (infinite) stays untouched.
Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService
dep) plus TenantRetentionGraceExpiring/Ended from the daily
TrialExpirySweep with the same stateless window mechanics, emitted only
when stored retention exceeds the Community cap; all dual-registered in
the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam
instead of inline DateTime.UtcNow. OKF doc
backend/plan-downgrade-retention-grace.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community/Pro comparison cards extracted into a shared module
(components/plans: planData.ts as single copy source, PlanCards.tsx with
marketing/portal surfaces) so the portal Plan section and the new public
/plans page can never drift. Pro price is one constant (null renders
"Pricing - announced soon").
New public pages following the /help pattern: /plans (free-first
messaging, nav item "Plans") and /buy (direct purchase + Microsoft
Marketplace as coming-soon channels). Landing nav, footer, FinalCta and
get-started now link to /plans instead of the docs plans page; portal
Plan section links to /buy via absolute www URL. Purple utilities
neutralized inside .dark .landing-v2; sitemap/robots/lastmod cover
/plans, /buy and the previously missing /help.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community tenants see a small "Community Edition" line under the brand
wordmark as a standing reminder (and nudge toward upgrade options); Pro
tenants (incl. trial) keep clean chrome. Label renders only after the
feature-flags fetch confirms the edition - no flash for Pro while loading.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IME reports skips and uninstall enforcements through the same completed
events, so both panels counted them as successful installs/downloads and
their pills contradicted the app_tracking_summary headers (session 502274b4:
"2 of 8 installed" vs "6 completed").
- Install fold: app_install_completed with state Skipped -> Skipped;
successful uninstall intent -> new terminal state Uninstalled with its own
pill and neutral row badge; completed now counts only real installs.
- Download fold: rows are classified by observed byte activity, never by
intent (Win32 uninstalls re-download the full package and stay visible
with an Uninstall badge); 0-byte rows sit behind a "no download" toggle.
- Both panels render tracker-known pending apps (summary pendingNames)
behind a "pending" toggle, so every "X of Y" header fully adds up.
- Headers wrap on narrow screens (flex-wrap + gap instead of space-x).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds www.autopilotmonitor.com/help as a mail-linkable page describing how
to get help: Report Session in the portal for session-specific problems,
GitHub Issues for everything else, LinkedIn for private contact, plus
self-service links (docs, FAQ, announcements, changelog) and the private
security-advisory path. Registered as public path in hostRouting (guard
test pinned) and linked from the site footer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fairstone.ca analysis 2026-08-21: after the reseal-reboot technicians routinely
power devices off at the logon screen to box them. Both silence paths (2h
maintenance sweep, agent stall probe) then labeled the expected parking state
"Stalled - Agent silent", and stage 2 later reconciled it to Succeeded on the
Part-1 whiteglove_complete with a reason claiming Account Setup completed.
- EnrollmentTimeoutClassifier: shared pure gate IsWhiteGloveAwaitingUser
(pre-provisioned + ResumedAt + Device Setup provisioned + zero user evidence
since resume) with one canonical customer-facing reason; new rollup field
HasEnrollmentComplete separates real completions from Part-1 whiteglove_complete
- ClassifyTimedOutSession: gated sessions are AwaitingUser within grace and
reconcile past grace with an honest WhiteGlove reason; on resumed sessions
whiteglove_complete no longer counts as terminal-complete evidence (rule 2),
so partial user progress falls through to the honest AwaitingUser/Incomplete rules
- MaintenanceService stage 1 + session_stalled ingest mapping apply the same
gate (fail-soft to Stalled); sweep audit now carries SessionsAwaitingUser
- Callsites (stage 2, max-lifetime verdict, late-telemetry reconcile, legacy
reclassification) pass IsPreProvisioned/ResumedAt through
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fmtDuration rolls hours into days above 24h ("768d 8h" instead of
"18440h 58m") - minutes are noise at day scale. Surfaced by the first
fleet session with a CMOS clock years off (0bd4af1c, 2024 -> 2026 step);
also keeps the slanted clock-marker label inside its headroom. Test
pinned on the real fleet delta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from
the Windows event log with its original timestamp, ~14min before the first
real activity) became the partition's earliest row and re-anchored the
session's StartedAt, inflating the duration toward pre-enrollment OOBE idle
time. With the watcher's 24h backfill lookback, the 2h backward-shift guard
only caps this effect, it does not prevent it.
SystemTimelineWatcher rows (clock steps, sleep episodes) are environment
observation, not enrollment activity, and are now excluded from all three
anchor derivations: the Events-table earliest-row probe (new
IsSessionAnchorEligible, probe scans past ineligible rows), the ingest
batch's EarliestEventTimestamp fold, and the web's client-side
enrollment-duration derivation. The events themselves stay on the timeline
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A left-edge "Clock -3h 0m" label collided with the HTML legend above the
band (first live fleet session d0c5b672). Marker-label headroom grows to
48 units so long labels stay inside the SVG, and the drawing content is
now inset 64 units on BOTH sides - the left inset mirrors the existing
right one, giving edge labels room and a calmer, symmetric frame.
Verified against the live session carrying the first system_clock_changed
event (backfilled step -> marker only, no rebase, as designed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consume the two new agent events:
- system_clock_changed: live-observed steps (timeDeltaMs authoritative,
>=30s; sub-30s NTP nudges ignored) split the stream into clock eras and
rebase every later timestamp by the cumulative delta, so the axis shows
true elapsed time. Backfilled steps (pre-agent, replayed from the event
log) are marked but never rebased. Each step gets a slanted "Clock +1h"
lifecycle marker with old->new time and reason in the tooltip.
- system_sleep_episode: episodes (emitted retroactively at wake) carve an
"Asleep" segment out of the active network instead of attributing the
quiet period to it; tooltip carries kind (Modern Standby/sleep), wake
source and AC/battery. Episode boundaries get the same era correction
as the event that carried them.
Stats row gains "Asleep total" and "Clock changes"; the event list
includes both new types (now titled "Network & system events").
Layout: drawing content stops 64 units short of the right viewBox edge
(CONTENT_W), so right-edge lifecycle labels slant up-right like all
others instead of flipping left.
7 new vitest cases pin era rebasing (live/backfilled/nudge), sleep
overlay incl. era-corrected boundaries, and hotspot confidence.
Live verification against fleet data pending - the emitting agent was
released minutes ago; no session carries the events yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two enrollment blind spots become timeline ground truth from the System event
log via one EventLogWatcher (WindowsUpdateTracker pattern, 24h backfill with
RecordId watermark recovering pre-agent records):
- system_clock_changed (Kernel-General 1): oldTime/newTime/signed timeDeltaMs,
reason and the setting process. 2s floor suppresses w32time 1ms micro-slews;
steps >= 5min are Warning + immediate upload. Payload instants are the
authoritative source (event timestamps can be clamped server-side).
- system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate,
Kernel-Power 507 Modern Standby; 506 deliberately unconsumed): kind,
enteredAt/exitedAt, durationSeconds, wake source / AC state. 60s real-sleep
floor filters Modern Standby screen-off scenarios.
ConfigVersion 39 (Collectors.EnableSystemTimelineWatcher +
SystemEventBackfillLookbackMinutes). Both types are non-activity on agent and
backend (idle/stall clocks unaffected; a sleeping device stays Stalled).
Consumers: TimeJumpBadge gains the ground-truth cause "clock-set"
(classifyTimeJump matches backward display steps against recorded clock sets);
EventTimeline badge blocks for both events plus the previously bare
power_state_change; SessionInfoCard keeps the wall-clock duration and adds a
standby note; TimeAttributionCalculator builds SleepSpans (RebootSpan clone,
AttributionVersion 3) persisted as SleepSeconds/SleepSpansJson and rendered in
TimeAttributionLane; ANALYZE-DEV-012 (info, single) fires once per session for
episodes >= 5min - deliberately the only new rule, clock findings stay with
DEV-008 to avoid duplicate-finding flood.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generated PowerShell command now uses the readable -Features form
(first catalog feature as the example) instead of raw permission strings;
a note below lists all available values incl. the All meta-feature and
how to fall back to -Permissions via Copy permissions. The section links
to the Optional Graph Permissions customer docs (via DOCS_URL registry),
and the "What this script does" bullet reflects the Cloud Shell
ambient-identity sign-in. Script help no longer claims the admin UI
generates the -Permissions form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gray lifecycle dots (Reboot / Desktop / Completed) were hard to hit
and mute without hovering. Their label is now written directly at the dot,
slanted up-right (flipping up-left near the right edge so it stays inside
the drawing), with the band shifted down to make headroom. The whole
marker group (line, dot, label) shares an enlarged invisible hit target,
and a label is suppressed when its neighbor sits too close - the tooltip
still carries the details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An SSID matching the typical iPhone hotspot name (".. iPhone") is strong
evidence on its own — such networks now get a plain "Hotspot" badge, while
subnet-only evidence keeps the hedged "Hotspot?". Tooltip and footnote
explain the distinction; detection also fires on iPhone-named SSIDs
outside the Apple hotspot subnet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Taskbar-style chips in the progress page status header showing the
device's last-reported power state (AC/battery with percent, low-battery
warning) and network state (WiFi with SSID tooltip + signal tiers,
Ethernet, offline). Derived purely client-side by folding the event
stream the portal already downloads (power_state_check/change,
network_interface_info, network_state_change, wifi_signal_info) - no
backend, API, or auth change. Chips render only while InProgress and
disappear gracefully for sessions from agents without these events;
devices without a battery show no power chip (Windows-tray parity).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All is a script-side convenience that expands to every GraphFeatureCatalog
feature (works with -Revoke too). The catalog sync test now pins All in the
ValidateSet while keeping it out of the catalog comparison, and the admin UI
mentions the shorthand next to the generated grant command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New /sessions/network-timeline?id=&tenantId= page visualizing every network
the device was on during enrollment: one colored segment per network
(Ethernet / WiFi SSIDs, fixed-order validated palette), hatched offline
gaps, ESP phase context lane, lifecycle + connectivity-check markers with
hover/tap tooltips, stats row and the raw network event list.
Segment model derives from network_interface_info + network_state_change
(before/after), enriches SSIDs from wifi_signal_info, infers missing
post-reconnect SSIDs via matching gateway, and flags probable smartphone
hotspots from vendor-default subnets (Apple 172.20.10.x, Windows
192.168.137.x, legacy Android 192.168.43.x) - always as a heuristic badge.
Axis is honest: linear per segment, minimum width for short segments,
boundary ticks show real times. Mobile scrolls horizontally.
Entry point: "Network timeline" deep link in DeviceDetailsCard's Network
section via new networkTimelineUrl() builder. Route registered in the SWA
config (exact routes before the /sessions/* wildcard, incl. the index.txt
RSC route) and excluded from LegacyPathRedirect's /sessions/{id} rewrite
via the new static-siblings set (test pinned).
Planned follow-up once the agent emits them: clock-change events (rebase
axis into clock eras) and standby/Modern-Standby events (render sleep as
its own segment kind).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Try Connect-MgGraph -Identity first (Azure Cloud Shell issues a delegated
token for the signed-in admin, avoiding the device-code flow that
Conditional Access commonly blocks), probe Graph readability, and fall
back to interactive sign-in with explicit scopes otherwise. The
AppRoleAssignment.ReadWrite.All scope guard now applies only to the
interactive path, since ambient-identity tokens carry a fixed scope set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Windows 11 24H2/25H2 (observed on builds 26200 and 26220, session
161b838c) the MS_Power_Management_Event_Provider is still registered in the
WMI repository but activating the extrinsic push query fails with
WBEM_E_NOT_FOUND - the legacy provider no longer loads on exactly the OS
builds new devices enroll with, so the power watcher degraded fleet-wide.
Arm chain: try the push event first (still alive on older builds), then
fall back to the intrinsic __InstanceModificationEvent WITHIN 30 on
Win32_Battery. The WITHIN sampling runs inside WinMgmt, not in the agent;
the handler path is unchanged (payload ignored, re-probe + diff), worst-case
detection latency ~35s incl. debounce. collector_degraded now fires only
when BOTH attempts fail; the arm log line names the active mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Event-driven AC/battery observation during enrollment (no polling): a WMI
Win32_PowerManagementEvent (EventCode 10) push subscription re-probes
GetSystemPowerStatus and diffs snapshots through a pure tracker into
power_state_change events - transition ac_to_battery (Warning, immediate) /
battery_to_ac (Info) / threshold_crossed at 50/30/15% (per-level lifetime
latch, lowest-crossed-only on jumps, arm-time baseline covers sessions
starting low, 5s trailing-edge debounce, emission cap 20). Never armed on
devices without a battery; arm failure surfaces as collector_degraded.
Kill-switch Collectors.EnablePowerStateWatcher (ConfigVersion 38).
power_state_change is classified non-activity on both sides
(SignalActivityClassifier + IsPeriodicOrStallEvent) so battery drain can
neither keep idle collectors alive nor heal a Stalled session.
New analyze rules, both with on_event:power_state_change interim triggers
and deliberately single-required-condition (event_data conditions scan
events independently - no same-instance join):
- ANALYZE-DEV-009 (high): thresholdPercent equals 15
- ANALYZE-DEV-010 (warning): transition equals ac_to_battery
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The JSON editors serialize the FORM object, but what users paste is usually a
rule EXPORT (e.g. received from another tenant). The create/edit whitelists
rebuilt collector parameters and evaluateOn from form fields the export does
not carry, so a pasted gather rule silently lost every collector parameter and
a pasted analyze rule silently reverted to the enrollment-end default.
Both rules pages get a single shape-tolerant mapper in types.ts:
- gatherRuleToForm unflattens the nested parameters object (authoritative when
present, so edit-merge leftovers cannot leak), maps nullable rule fields to
form defaults, imports a missing emitMode as 'always' (the rule's actual
behavior) instead of the empty-form on_change default, and now also backs
startEditing so edit and paste share one mapping.
- analyze jsonToForm maps evaluateOn onto the eval* checkboxes (an explicit
evaluateOn wins over stale form flags in edit-mode merges); terminal-only
exports fall back to the enrollment-end default.
- tags and the folded effective markSessionAsFailed now survive the import:
both forms carry tags into the create/save payloads, analyze creates carry
markSessionAsFailedDefault. notify/notifyChannelIds stay excluded — channel
ids are tenant-specific.
9 new vitest cases pin both shapes, the merge precedence, and the round-trips;
full web suite 824/824, tsc and eslint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Custom rules are tenant-partitioned and functionally collision-free, but three
surfaces still keyed on the bare non-unique ruleId string:
- RuleStats global aggregate (global_{ruleId}) summed same-ID custom rules of
unrelated tenants (title/severity last-writer-wins). The global row is now
catalog-only: analyze gates on IsBuiltIn/IsCommunity, gather gates on the
reserved-namespace pattern (agent events carry no flag; custom rules can
never occupy the reserved pattern).
- The 'never ship a built-in in the CUSTOM namespace' commitment from
RuleIdPolicy was documented but unenforced: combine.js now fails on
duplicate or non-reserved built-in IDs, BuiltInRuleCatalogPolicyTests pins
the same invariants on the embedded catalog, and the GitHub reseed rejects
non-reserved IDs before writing to the global partition.
- DeleteRuleStatesForRuleIdAcrossTenantsAsync (cross-partition delete by bare
RowKey) now refuses IDs outside the reserved namespace with failed=-1 so
callers abort the follow-up global catalog delete.
Verified: prod global partitions (45 analyze + 11 gather) are fully inside the
reserved namespace, so the guards touch no existing data. New OKF doc
docs/rules/rule-id-namespace.md captures the identity/collision model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The async queue correlation stored the event with Sequence 0, sorting it to
the top of the timeline (and tripping the time-jump badge) even though it
arrives last. Follow the BuildSessionTimeoutEvent/BuildAgentEmergencyBreakEvent
pattern: max+1 so it sorts last; WhiteGlove Part 1 anchors at the Part-1
terminal markers so a late device_setup report can never cross the web
timeline's sequence-based Part-1/Part-2 split. Also upsert the EventType
index the queue path never wrote, making the event visible to cross-session
search.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The IME processes the ESP app list in multiple passes (device-ESP evaluation
ending Skipped, real install up to an hour later), and AppInstallSummary
folded first observation -> last terminal across them: a 23-second app late
in the queue reported 4002 s (session 44862cd3, "Set TimeZone").
Backend ingest: new max-folded LastAttemptStartedAt anchors DurationSeconds
(CompletedAt - last attempt start; legacy rows without the anchor keep the
span fallback), replay-guarded additive InstallPassCount, attempt-scoped
DownloadDurationSeconds pairing, and a weaker-terminal guard (completed with
state Skipped/Postponed never overrides Installed/Error, in-batch and in the
cross-batch reconcile). StartedAt stays the earliest-wins window/version-
ordering key. AppsDashboardProjection carries the new columns.
AppVersionRegressionRadar: cutover gate — only attempt-anchored rows enter
the duration population on BOTH comparison sides, so mixed-semantics medians
can neither mask real regressions nor falsely re-arm active episodes.
Time attribution: per-pass segment pairing replaces the first-start ->
last-terminal hull. An app's seconds = sum of its ACTIVE segments (failed
attempts and evaluation passes count; inter-pass idle gaps don't), occupancy
merges the segments, hull endpoints remain for display and what-if bounds.
Agent: a terminal->active transition re-arms BOTH timing endpoints, so event
payload durationSeconds also describes the attempt.
Web/PDF/MCP: labels and tooltips say "install time (final attempt)", the
apps sessions table gets a xN multi-pass chip, the time-series chart carries
a cutover footnote, MCP tool descriptions document the semantics split
between get_app_install_metrics and get_time_attribution.
No backfill: pre-cutover rows keep span values (documented in the public
changelog). OKF doc: docs/backend/app-install-duration-attempt.md.
Suites: Functions 4383, Agent 2407, MCP 564, Web 814 - all green, build 0/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enrollment durations are heavily right-skewed; a handful of overnight/WhiteGlove
outliers made the Fleet Health and Dashboard average cards read like hours
(541 min) and answer no real question. Both cards now lead with the median
(robust 'typical enrollment' answer) and keep the tail visible via a P90
subtext. AvgDurationMinutes stays in both DTOs for API compatibility.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bbc7a1d8 stamped LastIngestAt into IncrementSessionEventCountAsync's merge
entity intending it as Sessions-only ("deliberately not mirrored"), but that
same entity flows through SyncSessionIndexAsync into the index merge — the
field was already landing in SessionsIndex on every ingest batch, just
undeclared. The fail-soft drift guard has been warning ~1,200x/h in prod
since the 08-20 deploy, and a StartedAt-shift full rebuild would silently
drop the column, making index-served session reads lose lastIngestAt that
Sessions-served reads carry.
Declare it properly instead of suppressing: ConditionallyProjected in
SessionIndexFieldManifest, projected in BuildSessionIndexEntity (read side
MapToSessionSummary already handled it), sample value pinned in the
bidirectional manifest tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 60-min RealmJoinTimeout deadline arms at RJ detection (the RJ agent MSI
install during DeviceSetup) and used to cut off monitoring mid-install: report
55e6afd61c9d (session 46a96078) lost the tail of a large package catalog whose
last observed package completed 3 seconds before the deadline, with the first
deployment only starting 16 minutes into the window.
When the deadline now fires with LastDeploymentPhase 100/101 and deployment
activity (phase change or package observation, persisted restart-safe in
RealmJoinFacts.LastActivityUtc; the detection-time phase capture deliberately
does not count) inside the last 60 min, the handler re-arms to
lastActivity + 60 min instead of timing out — Info event
realmjoin_timeout_extended — clamped at an absolute cap of detection + 4 h.
realmjoin_timeout carries a reason payload (hard_timeout / inactivity /
absolute_cap); a fire of a deadline incarnation superseded by a re-arm
dead-ends via the OccurredAtUtc == DueAtUtc scheduler contract. The idle case
(no activity ever) still times out at 60 min, and phase-200/210 activity never
extends. Packages are only registry-visible at completion, so the inactivity
window keeps the full original 60 min.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent stamps X-Send-Time-Utc (device clock, ISO-8601 round-trip) per upload
ATTEMPT; ingest parses it (year>=2000 guard, deliberately no upper clamp) and
server-stamps SentAt onto every event of the request. SentAt - OccurredUtc is
pure spool delay, ReceivedAt - SentAt is network + device-vs-server clock
offset - previously indistinguishable and only separable via median statistics.
clock_skew condition: batches carrying SentAt measure the frame directly
(spool-immune by construction - no spread cap, IME/clamped exclusions
irrelevant, IME-only sessions become measurable; evidence sentAtBatchCount);
legacy batches keep the per-event median path unchanged. CmTraceSkewTripwire
deliberately untouched: its differential median math cancels clock offset as
common mode.
Full persistence chain: Store x2 + Map, fields= projection (sentAt), MCP
get_session_events field list + rule-authoring-guide. New OKF doc
backend/send-time-frame-separation.md.
Suites: Functions 4370 (+16), Agent 2407 (+1), MCP vitest 564 + tsc 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The labeled provenance rows duplicated values already visible in the
raw JSON dump and inflated the metadata block for every IME event. The
section is now collapsed by default behind a small "time provenance"
toggle sitting inline on the Created row (whose value it explains) -
zero extra height when collapsed, and the base label column returns to
its original width, so the default detail view is pixel-identical to
before P13. Expanding reveals the same rows, indented under Created.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-line self-anchoring provenance (sourceLocalTs, applied offset +
origin, observational writer offset, derived/rejected markers) reaches
the browser but was buried unlabeled in the raw JSON dump. Sequence
stays THE sort order - without exception (now pinned by a regression
test); the corrected UTC is display-only, and a backwards step of the
displayed time within sequence order is marked as information instead
of confusing the reader.
- lib/timeProvenance.ts: readTimeProvenance (camelCase/snake_case,
tolerant of truncated payloads) + classifyTimeJump with cause
precedence derived > rejected-source > era-offset; era-offset
requires applied offsets on BOTH rows - it never guesses.
- TimeJumpBadge on the row AFTER the jump, computed against the
previous RENDERED row (filters/phase boundaries respected); sky for
known causes, amber otherwise; tooltip carries both times, the delta
and the "true write order (sequence)" sentence. Threshold 5 min,
field-calibrated on 20 sessions: 12/12 normal sessions show zero
badges, the era-mixed regression session shows exactly its 6.
- Clamped chip (rare) + TIME PROVENANCE detail section: only the
applied offset says "(applied)"; the writer offset is explicitly
observational (sticky after era flip-backs); origin chips with
forward-compatible fallback for unknown values.
- EnrollmentEvent gains timestampClamped/originalTimestamp (wire
already carried them); formatUtcOffset joins lib/formatting.
Verified: 814 web tests (19 new), tsc/eslint/build clean; Playwright
against e9753578 (badges match the pre-measurement), 9f475bb2 (no
badge on line-anchored rows, detail section correct, raw mode shows
exactly the one predicted jump).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator side: CmTraceSkewTripwire piggybacks on the terminal counter
reconcile's partition scan (zero extra reads) and compares median
ReceivedAt-OccurredUtc deltas of IME-derived vs other events. A clean
15-min-grid divergence (residual < 2 min, >=20 samples / >=3 distinct
batches per side) emits the CmTraceTimeSkewRegression ops event
(Agent/Warning, goal: never fires). Bias-dominated sessions are
suppressed; measuredWriterOffsetMinutes is never consulted (sticky
after era flip-backs). Kill switch: CmTraceSkewTripwireDisabled.
Customer side: new clock_skew analyze condition source (skewMetric
clock_jump | sustained_offset, threshold seconds, gt/gte magnitude)
behind built-in ANALYZE-DEV-008 (300 s = the Kerberos/Entra 5-min skew
boundary). Batch-median frames with spool-spread filter, plateau step
detection (a gradual backlog-drain ramp cannot fake a jump) and
end-state persistence; IME-derived events are excluded so a CMTrace
anchoring regression can never fire the customer rule. Evidence carries
field=skewSummary/value for {{skewSummary}} interpolation.
Full mirroring chain: schema enum + skewMetric property, shared
manifest + combine.js regeneration, MCP authoring guide + semantic
lint, web SOURCES/form/card, ops-event dual-register, docs. P5 is
subsumed by the tripwire. PercentileMath is now the single nearest-rank
implementation (Apps/PlatformMetrics delegate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field forensics from the 1412 verification run (session 067797d8): the
label/anchor crossing recurred on a provably genuine binary, and the
new anchor-timestamp tripwire delivered — the "+02:00 measured for
IntuneManagementExtension.log" anchor (local 19:51:26.948) does not
exist in ANY log file on the device, and IME.log never produced its
expected first measurement during 24 s of growing passes beforehand.
Mechanism still unexplained; per-line anchoring keeps resolution
immune (all 58 line-anchored events of the session were correct), but
the observational layer must convict the culprit next time:
- CalibrateFrom now receives the iteration's ACTUAL file name next to
the calibration key. A mismatch — the smoking gun the two field
occurrences imply but static analysis says is impossible — logs a
Warning with both names plus the anchor, and refuses the anchor, so
even the unexplained path can no longer poison the measurement store.
- The first calibrator rejection per file logs at Debug (anchor local
time + clock). The 24 s of silent non-measurement is exactly what
made the phenomenon undiagnosable from the agent log.
232 IME tracker tests green against a verified-clean build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:18:35 +02:00
2354 changed files with 255057 additions and 46195 deletions
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. The plan file is `internal/work/plans/<slug>.md` from the first draft; the harness plan folder outside the repo is never the storage place, at most a copy for the approval dialog. If something goes sideways, stop and re-plan instead of pushing on.
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
Codex reviews your output once you are done.
## Where knowledge lives
| Content | Location | Rule |
|---|---|---|
| Customer documentation | separate repo `autopilotmonitor-docs` (GitBook; baked into the MCP `search_docs` corpus) | Any change ⇒ MCP redeploy. Commit in that repo (`cd` first). |
| Technical concept docs (OKF bundle) | `internal/` — private submodule, entry point `internal/docs/index.md` | Read the index first. Concept docs only for invariants, mechanisms and architecture a later change could silently break. |
| Decisions, including "we will not build X" | `internal/decisions.md` | One entry per decision: date, decision, why, applies-to, status. |
| Working files, lessons, plans | `internal/work/` | Rules in `internal/CLAUDE.md`. |
| Agent review criteria | `internal/docs/agent/architecture-principles.md` | Check every agent change against it. |
| Private operator instructions | `internal/CLAUDE.md` | Imported at the end of this file. Absent for contributors without submodule access; nothing in it may be copied into this public repo. |
| Infrastructure and operator scripts | `infra/` — private submodule | Bicep plus deploy/backup/migration scripts. Scripts never carry credentials; secrets come from env or a gitignored file next to the script. |
| Skills | `.claude/commands/` — private submodule | Each skill's frontmatter says when to use it. |
| Claude auto-memory | outside the repo, per machine | Private notes only. Anything the project must keep goes into `internal/`. |
## Hard rules
- Agent projects stay on .NET Framework 4.8.
- .NET build warnings are errors; only the NU1901–NU1904 advisories remain warnings.
- Never put real customer domains, tenant names or tenant IDs into code, tests, commits, comments or public docs.
- Deploy order is Backend → Web → MCP. Backend deploys are `workflow_dispatch` only. Verify the deployed version before claiming anything is live.
- Submodules (`internal/`, `infra/`, `.claude/commands/`): commit inside the submodule first, then bump the gitlink here.
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
## Code Quality
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
- English for code comments and everything that carries knowledge.
## Task Management
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
## Customer-Facing Claims
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
## Technical Docs — OKF Bundle (`docs/`)
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
Private half of these instructions (process, working files, technical docs, operations): @../internal/CLAUDE.md
description:Something does not work as documented.
labels:[bug]
body:
- type:markdown
attributes:
value:|
Thanks for the report. Please redact tenant IDs, device names, serial numbers, and anything personal before posting.
For a failed enrollment in your own tenant, the fastest path is the **Report Session** button in the portal — it attaches the session context for support without exposing it here.
- type:dropdown
id:component
attributes:
label:Component
options:
- Web portal
- Agent (on device)
- Bootstrap script / Intune deployment
- MCP server / AI integration
- Analysis rules / log patterns
- Backend / API
- Documentation
- Not sure
validations:
required:true
- type:textarea
id:what
attributes:
label:What happened?
description:What did you do, what did you expect, what happened instead?
validations:
required:true
- type:textarea
id:repro
attributes:
label:Steps to reproduce
placeholder:|
1. …
2. …
- type:input
id:version
attributes:
label:Agent version / portal date
description:Agent version from the session details, or the date you saw it in the portal.
- type:textarea
id:logs
attributes:
label:Logs or screenshots
description:Relevant excerpts only, redacted. Paste text rather than screenshots of text where possible.
throw"Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See docs/versioning.md."
throw"Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See internal/docs/versioning.md."
- name:Every third-party action is pinned to a SHA
run:|
set -uo pipefail
failed=0
while IFS=: read -r file line ref; do
# Reusable workflows from this repository (workspace-relative ./ or the
# self-repository form $/) move with the commit that calls them, so there is
# nothing to pin.
case "$ref" in ./*|\$/*) continue ;; esac
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$) ]]; then
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
continue
fi
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vX.Y.Z' so the line stays reviewable."
elif [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+# ]]; then
echo "::error file=$file,line=$line::The version comment must name the exact release tag the SHA resolves to ('# vX.Y.Z'), not a floating major: '$ref'. Find it with: gh api repos/<owner>/<action>/git/matching-refs/tags/vN"
else
echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos/<owner>/<action>/git/ref/tags/<tag>"
fi
failed=1
# -H is load-bearing: with a single matching file grep omits the name, the
# fields shift by one and every diagnostic points at nothing.
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
This ZIP carries a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
}
# Previous release for the compare link. Fail-soft: the first release has none,
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
# Fail-soft like the star count: browsers still load the stats live; only crawlers and
# LLM fetchers, which run no JS, see no numbers until the next deploy.
grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
# The SWA action reporting success proves the upload, not that the production host
# serves the new build — verify against the live /version.json (Cache-Control:
# no-store via staticwebapp.config.json) before publishing the version manifest.
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name:Package export
id:package
run:|
archive="portal-${GITHUB_SHA::7}.tar.gz"
tar -C "$WEB_DIR/out" -czf "$archive" .
echo "archive=$archive" >> "$GITHUB_OUTPUT"
sha256sum "$archive"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
This applies to all project spaces: issues, pull requests, discussions, and the documentation repository.
## Expectations
- Be respectful and constructive. Disagree with ideas, not with people.
- Assume good intent. Most confusion is a documentation gap, not malice.
- Keep discussions on topic and searchable: clear titles, reproducible steps, text instead of screenshots of text where possible.
- Never post customer data, tenant IDs, device identifiers, tokens, or log excerpts containing personal data in public issues or discussions. Redact first.
## Not acceptable
Harassment, personal attacks, discriminatory language, publishing others' private information, and sustained disruption of discussions.
## Enforcement
The maintainers may edit, hide, or delete content and may block accounts that violate this code. To raise a concern, contact the maintainers privately through
[GitHub](https://github.com/okieselbach) rather than in a public thread.
Autopilot Monitor is developed and operated by its maintainers. The source is published so that customers and security reviewers can see what runs on their devices and with their data. This page explains where feedback goes.
| Ask a question or discuss an idea | [Discussions](https://github.com/okieselbach/AutopilotMonitor/discussions) |
| Report a security issue | [SECURITY.md](SECURITY.md) — privately, never as a public issue |
| Read how the product works | [docs.autopilotmonitor.com](https://docs.autopilotmonitor.com) |
## Reporting a problem in your own tenant
For a failed or odd enrollment, the **Report Session** button in the portal is the fastest path. It hands the session context to support without exposing any of it publicly. Use a GitHub issue for problems that are reproducible and not tied to your data.
Please redact tenant IDs, device names, serial numbers, and anything personal before posting in a public issue or discussion.
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
Autopilot Monitor is an open-source, real-time monitoring and troubleshooting platform for **Windows Autopilot** and **Microsoft Intune** enrollment. It records enrollment events, application installations, policies, device state, and diagnostics while provisioning is still running, instead of collecting logs by hand after a failure. Administrators get live enrollment tracking, Enrollment Status Page (ESP) visibility, detailed session timelines, automated issue detection, on-demand diagnostics, fleet health analytics, and AI-assisted troubleshooting through a hosted Model Context Protocol (MCP) server.
The platform supports classic Windows Autopilot and modern Intune provisioning scenarios, including **Windows Autopilot Device Preparation**.
## Availability
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
<img src=".github/assets/SessionList.png" width="45%" alt="Autopilot Monitor dashboard showing live Windows Autopilot and Intune enrollment sessions" />
<img src=".github/assets/FleetHealth.png" width="45%" alt="Autopilot Monitor fleet health dashboard with Windows Autopilot enrollment analytics and success metrics" />
Autopilot Monitor provides real-time tracking, intelligent diagnostics, and automated troubleshooting for Windows Autopilot enrollment processes. It consists of:
- **Real-time Windows Autopilot monitoring** — follow device provisioning while enrollment is still running
- **Detailed enrollment timelines** — correlate Autopilot, ESP, Intune Management Extension, apps, policies, device state, and system events
- **Enrollment Status Page visibility** — see which phase, application, or policy is delaying provisioning
- **Automated issue detection** — define custom analysis rules that identify known failure patterns
- **Targeted evidence collection** — gather registry values, event log entries, files, log-parser matches, and allow-listed commands when specific conditions are detected
- **Centralized agent configuration** — control diagnostic behavior, automatic reboots, timezone adjustment, and other monitoring settings from the web dashboard
- **AI-assisted troubleshooting** — query enrollment data through the built-in Model Context Protocol server
- **Bootstrap Script** — PowerShell script deployed via Intune that starts monitoring early in the enrollment process
- **Monitoring Agent** — Lightweight .NET application that collects telemetry and evidence during enrollment
## How it works
Autopilot Monitor consists of several components working together throughout Windows enrollment:
- **Bootstrap Script** — PowerShell script deployed through Microsoft Intune that starts monitoring early in the provisioning process
- **Monitoring Agent** — lightweight .NET application that collects telemetry and evidence during enrollment
- **Backend API** — Azure Functions-based ingestion and processing pipeline
- **Web Dashboard** — Next.js application for real-time monitoring and fleet analytics
- **Web Dashboard** — Next.js application for real-time monitoring, troubleshooting, and fleet analytics
- **MCP Server** — Model Context Protocol server that lets AI assistants query Autopilot Monitor data in natural language
## Architecture
## Windows Autopilot and Intune Troubleshooting
For detailed information about the system architecture, components, and data flow, see [Architecture Documentation](docs/architecture.md).
Autopilot Monitor helps investigate common Windows provisioning and enrollment problems such as:
- Windows Autopilot deployments that fail or appear stuck
- Enrollment Status Page (ESP) delays and unusually long deployment phases
- Win32 application installation failures and slow application downloads
- Intune Management Extension problems
- Microsoft Entra join, Hybrid Join, and device registration issues
- policies that block or delay provisioning
- inconsistent behavior across hardware models and recurring failures affecting multiple devices
Because telemetry is collected during enrollment, many problems can be investigated without reproducing the issue or manually collecting logs from the affected device.
## AI Integration (MCP)
Autopilot Monitor ships a hosted **[Model Context Protocol](https://modelcontextprotocol.io)** server. Connect Claude Desktop, VS Code, or any MCP client that speaks Streamable HTTP with OAuth, and ask questions like:
- *"Show me all failed enrollments from the last 24 hours and group them by failure reason."*
- *"Why did session X fail, and which app install caused the delay?"*
- *"Which devices had unusually long ESP application phases this week?"*
- *"Which device models have the highest Autopilot failure rate?"*
- *"Which devices in my fleet are affected by CVE-2024-30078?"*
Server URL: `https://mcp.autopilotmonitor.com/mcp` — sign-in runs through your existing work account, access is scoped to your tenant exactly like in the portal, and the MCP server stores no credentials. Setup guide: **[AI Integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp)**.
## Documentation
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)** — deployment, configuration, monitoring, diagnostics, troubleshooting, analysis and gather rules, integrations, and AI-assisted troubleshooting.
## Open Source
Autopilot Monitor is developed openly on GitHub. The source is published so that customers and security reviewers can see what runs on their devices and with their data: the repository contains the device agent, shared library, backend, web dashboard, and MCP server.
## Feedback
Bug reports and feature requests go through [Issues](https://github.com/okieselbach/AutopilotMonitor/issues/new/choose), questions and ideas through [Discussions](https://github.com/okieselbach/AutopilotMonitor/discussions). Security issues are reported privately, see [SECURITY.md](SECURITY.md). For a problem in your own tenant, the **Report Session** button in the portal is the fastest path.
## License
This project uses a **split licensing model**:
This project uses a **split licensing model**. The root [LICENSE](LICENSE) is AGPL-3.0, which covers the server-side components; the device-side components carry their own MIT license file:
- **MIT License** — Agent (`src/Agent/`) and Shared library (`src/Shared/`) — unrestricted use on end-user devices
- **AGPL-3.0** — Backend (`src/Backend/`), Web Dashboard (`src/Web/`), and MCP Server (`src/McpServer/`) — server-side components remain open source
- **MIT License** — Agent ([`src/Agent/`](src/Agent/LICENSE)) and Shared library ([`src/Shared/`](src/Shared/LICENSE)) — unrestricted use on end-user devices, no copyleft obligations
- **AGPL-3.0** — Backend ([`src/Backend/`](src/Backend/LICENSE)), Web Dashboard ([`src/Web/`](src/Web/LICENSE)), and MCP Server ([`src/McpServer/`](src/McpServer/LICENSE)) — modifications to server-side components remain open source, especially when deployed as a network service
See [LICENSE](LICENSE) for full details.
The Shared library is MIT because it is a dependency of the MIT-licensed Agent. The license file inside each component directory is authoritative for that component.
Write-Host" 2. Start Functions: cd src\Backend\AutopilotMonitor.Functions && func start"-ForegroundColorGray
if($IncludeWeb){
Write-Host" 3. Start Web UI: cd src\Web\autopilot-monitor-web && npm run dev"-ForegroundColorGray
Write-Host" 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console"-ForegroundColorGray
}
else{
Write-Host" 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console"-ForegroundColorGray
description: Windows' own diagnostic surfaces for the Autopilot profile-download (ZTD) flow and known deployment error codes — what the agent reads, what each identifier means, and where the knowledge comes from.
| Event ID | Level | Meaning | Agent verdict mapping (`ztdVerdict`) |
| --- | --- | --- | --- |
| 100 | Warning | "Autopilot policy [name] not found" — temporary, device is waiting for the profile download. Windows can log hundreds of these per minute (harmless-rollup in `ModernDeploymentTracker`). | `waiting_for_profile_no_internet_confirmation` (when nothing else was logged) |
| 101/103/109/111 | Info | Profile settings retrieval/processing details. | not queried |
| 153 | Info | `ProfileState_Unknown → ProfileState_Available` — a profile was downloaded and is ready. | `profile_downloaded` |
| 160 | Info | Profile settings acquisition beginning. | context only |
| 163 | Info | Download not required — device already provisioned (typically after `Sysprep /Generalize`, i.e. Autopilot-for-existing-devices images). | `already_provisioned` |
| 164 | Info | Internet available to attempt policy download — the authoritative witness that the ZTD round-trip could run at OOBE time. | with no 161: `download_attempted_no_profile_returned` (the assignment-gap signature, session 423b5360) |
| 171 | Error | TPM identity confirmation failed (HRESULT in payload) — self-deploying/pre-prov attestation. | context only (`inconclusive` if alone) |
| 172 | Error | Failed to set profile as available — usually follows 171. | context only |
| 807 | Error | `ZtdDeviceIsNotRegistered` — hardware hash not registered in Autopilot. | `device_not_registered` |
| 809 | Error | `ZtdDeviceHasNoAssignedProfile` — the assigned profile was deleted without cleanup. | `assigned_profile_deleted` |
| 815 | Error | `ZtdDeviceHasNoAssignedProfile` — no profile assigned and no tenant default profile. | `no_profile_assigned` |
| 908 | Error | `SerialNumberMismatch` / `ProductKeyIdMismatch` between Autopilot registration and physical hardware. | `serial_or_product_key_mismatch` |
Verdict priority: error IDs are authoritative (807 → 908 → 809 → 815), then the Info-flow
reconstruction (161/153 → 163 → 164 → 100). Implemented in
`ZtdEvidence.ComputeZtdVerdict`, pinned by `ZtdEvidenceVerdictTests`.
# Schema — Diagnostics registry key
`HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot` — written by the ZTD client,
dumped verbatim by the agent into the `autopilot_profile` event (`diagnosticsRegistry`):
| Value | Meaning |
| --- | --- |
| `AadTenantId` | GUID of the tenant the user signed into. |
| `CloudAssignedTenantDomain` | Tenant the device is registered with; **blank = not registered with Autopilot**. |
| `CloudAssignedTenantId` | GUID matching `CloudAssignedTenantDomain`; blank when not registered. |
| `IsAutopilotDisabled` | 1 = device not registered **or** profile download failed (network/firewall/timeout) — Microsoft's own catch-all indicator. **Casing caveat:** Windows actually writes `IsAutoPilotDisabled` (capital P; observed session e7dbac7a, 24H2) while the FAQ documents `IsAutopilotDisabled` — all agent lookups are case-insensitive for this reason. |
| `TenantMatched` | 0 = user's tenant ≠ registration tenant → the "username belongs to another organization" OOBE error. |
The profile cache itself lives in `HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache`
(`PolicyJsonCache`, `ProfileAvailable`) — `ProfileAvailable=0` plus an empty
`ZeroTouchConfig` is the "blank profile cached" state the troubleshooting FAQ describes
when no profile exists at check time. A reboot re-runs the download and can replace the
blank cache.
# Schema — Endpoints
| Endpoint | Purpose | Agent behavior |
| --- | --- | --- |
| `https://ztd.dds.microsoft.com` | Autopilot Deployment Service — profile delivery during OOBE. | Reachability probe on profile-missing (any HTTP status = reachable). **Note:** several third-party blogs cite `ztd.ssd.microsoft.com`; the official requirements page says `ztd.dds.microsoft.com`. |
| `https://login.live.com` | Second Deployment-Service URL; also needed by the Microsoft Sign-in Assistant (`wlidsvc`) — a policy that disables wlidsvc breaks profile download. | not probed |
| `*.microsoftaik.azure.net` (+ per-vendor EK cert URLs) | TPM attestation for self-deploying/pre-provisioning. | not probed |
| `lgmsapeweu.blob.core.windows.net` | Intune automatic diagnostics upload. | deliberately not probed (decision 2026-07-16: not mission-critical) |
# Observability — when the agent can actually see these errors
The agent's install timing bounds what the known-issue rules can ever observe. In the
normal deployment model the agent is installed by an **Intune platform script via IME —
i.e. only AFTER MDM enrollment succeeded**. Everything that happens before that point
(profile download, TPM attestation in self-deploying/pre-provisioning, the MDM enrollment
handshake itself) is over before our process exists.
| Failure class | Normal flow (IME install) | Bootstrap token mode | Await-enrollment mode |
| --- | --- | --- | --- |
| Enrollment blocked (0x80180014/18, 0xC1036501, 0x801C03F3 → ANALYZE-ENRL-004) | **Invisible** — no enrollment ⇒ no agent, no session. | **Visible** — the bootstrap token exists precisely to report pre-MDM (as of 2026-07: one customer piloting). | **Invisible to the backend** — the agent binary is present but only polls for the MDM certificate; without the cert it cannot authenticate, and monitoring starts only after the cert appears. A hard blocker ends in the await timeout (default 480 min) with nothing uploaded. |
| TPM attestation (ANALYZE-ENRL-003) | **Visible via error backfill** — attestation precedes enrollment, but the startup error backfill (below) replays Autopilot-channel Level 1-2 records from the last 4h, so retry-then-success attempts surface as `backfilled=true` events. | Visible. | Visible via error backfill once monitoring starts. |
| Hybrid-join 0x80004005 (ANALYZE-ENRL-005) | **Visible** — the ODJ/hybrid phase runs during device ESP while the agent is active. | Visible. | Visible after cert arrival. |
| Clock skew (ANALYZE-DEV-007) | **Always** — ntp_time_check is our own event. | Always. | Always. |
The rules stay enabled despite the narrow normal-flow windows: they are evidence-gated
(zero cost when the codes never appear) and they future-proof the bootstrap and
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
records **oldest-first** so the timeline sequence follows event-log chronology.
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
Error filter.
# Schema — Error codes (backend known-issue map)
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
failures we can observe:
| Code | Meaning / context | Actionable guidance |
| --- | --- | --- |
| `0x800705B4` | Timeout; in self-deploying mode usually "device is not TPM 2.0 capable" (e.g. a VM). | Don't use self-deploying on VMs / non-TPM-2.0 hardware. |
| `0x801C03F3` | Entra device object for the Autopilot device was deleted (logged in `Microsoft-Windows-User Device Registration/Admin`). | Deregister + re-register the device (recreates the object). |
| `0x80180014` | Device-record reuse in self-deploying/pre-prov mode, or Windows MDM enrollment disabled ("Enrollment blocked for AP device by SDM One Time Limit Check"). | Intune → Autopilot device → **Unblock device**, or allow Windows (MDM) in enrollment restrictions. |
| `0x80070774` | Hybrid-join ESP failure — domain mismatch between Intune Connector for AD and device targeting. | Install/configure the connector in the matching domain. |
| `0x80004005` | Hybrid-join deployment timeout — **build-dependent known issue**, fixed in KB5065789 (25H2, ≥ 26200.6725), KB5065426 (24H2, ≥ 26100.6584), KB5070312 (23H2, ≥ 22631.6276). | Update the image / install the KB. |
Related non-code known issues worth remembering: clock skew of minutes causes TPM
attestation errors and ESP timeouts (`w32tm /resync /force`; the agent's `ntp_time_check`
event carries the measured offset); multiple/unexpected OOBE reboots can delete kiosk
autologon registry entries (fixed in 24H2 KB5058411); ESP does not support mixing LOB and
Win32 apps ("Another installation is in progress").
title: Agent Build Numbers — Shared Counter Blob with ETag-CAS
description: Why agent build numbers come from a public counter blob reserved before the build, how the compare-and-swap protocol works, and what the repo's buildcounter.txt still does.
resource: /.github/workflows/build-agent.yml
tags:
- agent
- build
- ci
- release
- versioning
timestamp: 2026-07-17T00:00:00+02:00
---
# Agent Build Numbers — Shared Counter Blob
Agent (V2 line) build numbers — the `<n>` in `2.0.<n>` — are minted from a single
shared counter blob so that **local builds (`scripts/Deployment/build.ps1`) and CI
builds (`.github/workflows/build-agent.yml`) can never collide**. Both build paths
stay first-class: local daily-ops builds remain possible, CI builds add provenance
attestation on top.
# Schema
The reserve-before-build CAS protocol itself is shared with the backend and the MCP
server and is documented once in [Version Contract](../versioning.md); the single
implementation is `.github/scripts/Request-BuildNumber.ps1`. Agent-specific:
title: CMTrace Time Resolution — Per-Line Self-Anchoring
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
description: Operator-set tenant opt-in that turns an immediate Device-phase ESP terminal failure on a Continue-Anyway-enabled profile into a bounded 60-minute observation; the DAD-validated real-user desktop plus the Hello gate detect the true end, and the session completes as Succeeded with an esp-soft-failure marker.
description: The pure-reducer state machine that decides enrollment completion/failure — signals, state, effects, the A/B/C completion arms, and its invariants.
- **`InformationalEvent`** — the single-rail pass-through: telemetry that must appear
on the Events timeline but never influences decisions.
**Ordering is the ingest ordinal, never wall-clock.** Replayed IME/CMTrace lines carry
backdated source timestamps; all before/after comparisons in the reducer therefore
compare `SourceSignalOrdinal`s (e.g. "final exit after AccountSetup entry").
# Effects — the only side-channel
`DecisionEffect` kinds and how the host's `EffectRunner` treats failures:
| Kind | Purpose | Failure class |
| --- | --- | --- |
| `ScheduleDeadline` / `CancelDeadline` | arm/cancel OS timers that come back as `DeadlineFired` | **critical** — failure aborts the session (a lost timer means silent parking) |
| `PersistSnapshot` | hint to write the recovery snapshot | transient |
| `RunClassifier` | scenario classification; verdict returns as a `ClassifierVerdictIssued` signal | optional — exception ⇒ Inconclusive, never aborts |
Terminal and state-changing timeline effects carry a structured **audit trail** in
`TypedPayload` (built by `DecisionAuditTrailBuilder`): `decisionSource`, `trigger`,
description: How the backend re-homes agents to a new API base URL (backend move or per-tenant region move) via MigrateToApiBaseUrl on the agent config response, and why HTTP redirects were rejected.
title: ESP Policy-Provider Stall Detection — the no-timeout Setup/Apps wait and the co-management Sidecar gap
description: Why the ESP's Apps wait is keyed to the Intune-registered "Sidecar" provider by name — a foreign provider (ConfigMgr) with TrackingPoliciesCreated=1 does not satisfy it — plus the classic never-completes stall, and how the always-on two-arm esp_policy_provider_stalled tripwire (reason=provider_incomplete | sidecar_provider_missing, 15-min wall-clock dwell, StartupEventGate cross-restart dedup) surfaces both.
* Windows Autopilot into co-management (requirements, provider-conflict warning, troubleshooting registry paths) — <https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment>
* Troubleshoot the ESP (models app tracking exclusively via the `Sidecar` subkey) — <https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/understand-troubleshoot-esp>
* Niehaus: the Intune ESP-policy WAP payload registering the provider nodes (`Sidecar` always, `ConfigMgr` only with a co-management settings policy) — <https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/>
* Niehaus: ESP tracks arbitrarily named providers when they are properly registered — <https://oofhours.com/2023/09/18/track-anything-using-esp/>
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
A gather rule that produces no timeline events is indistinguishable from a rule that
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
them were visible to the customer:
| # | Decision point | Where |
|---|---|---|
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
title: Hello Wizard Un-Skip — vetoing the policy-disabled Hello shortcut
description: Why a single "WHfB disabled" policy read is not trusted for completion decisions — the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
Reading order for newcomers: overview → decision engine → persistence.
description: Every file the agent persists under %ProgramData%\AutopilotMonitor, why each exists (especially the signal log), and the exact recovery/replay flow after a crash or reboot.
| Startup-event gate | `State\startup-event-state.json` | JSON dict | no (atomic) | cross-reboot dedup of one-shot probes |
| Bandwidth state | `State\bandwidth-state.json` | JSON samples/counters | no (atomic) | reboot-survivor for the DO bandwidth estimate |
| Session identity | `session.id`, `session.created`, `whiteglove.complete` (root) | text | no | drives emergency-break age check + WG resume |
| Markers | `clean-exit.marker` (root), `enrollment-complete.marker` (State), `quarantine-requested.marker` (State) | text | no | consumed at next start |
title: MDM Reboot Coalescing — attributing the mid-ESP reboot and second sign-in to policy URIs
description: How the agent observes reboot-required MDM policy flags (DM-Enterprise EventID 2800, aggregated) and how ANALYZE-ESP-005 attributes an actually-observed enrollment reboot to them — including why the raw events stay neutral and why the Shell-Core RebootCoalescing match was removed.
confidence: `uriCount ≥ 2` (+10), `omadmRebootRequiredFlag == true` (+15) on base 70 /
threshold 70. Remediation: map URIs to Intune profiles, reassign device → user groups (or
exclude the Autopilot device group), or accept + communicate the restart.
# Non-goals / invariants
* Event type **deliberately distinct from `system_reboot_detected`** — the backend
RebootCount matches that string exactly in two places
(`EventIngestProcessor.Classification` + terminal recount); attribution must never
inflate the reboot count.
* No session-level field / SessionInfoCard change in this iteration (follow-up candidate).
* The `RebootRequiredURIs` catalog itself is not read; only the 2800 events (which carry
the actually-triggered URIs) are consumed.
# Citations
* [Patch My PC — Avoid the second sign-in screen after Autopilot](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/)
* [Microsoft Learn — Enrollment Status Page tracking](https://learn.microsoft.com/en-us/autopilot/enrollment-status)
description: How the V2 agent boots during Autopilot enrollment, which collectors it runs, how telemetry flows to the backend, and how it decides to stop.
title: ESP Completion Starvation — the sits-d Cloud-PC complex
description: Why five Windows 365 Cloud PCs finished enrolling but never completed in the product — a SkipUser=true flow produces none of the signals every completion-gate evaluation site hangs off — plus the two adjacent defects fixed alongside (edge-only user-apps-settled synthesis, orphaned Hello-wizard restart replay) and what a Shell-Core replay deliberately does not do.
| `HandleImeUserSessionCompletedV1` arm-C attempt | requires the recorded final exit | unsatisfiable |
No knock, no promotion, no `HelloSafety` — and the `AdvisoryCompletion` backstop is armed
exclusively from an `EspExiting` signal, so it never armed either. The session idles to
the watchdog with an open gate.
## The fix — the observed skip is the exit evidence
`HandleImeUserSessionCompletedV1`'s completion attempt now accepts the observed skip in
place of the final exit (`skipUserExitEquivalent`): on a flow where no page exists, no
exit can ever say more than the `FirstSync` read already did. Everything else stays
mandatory — AccountSetup anchor, genuine (at-or-after-anchor) IME user-session completion,
DAD-validated real-user desktop — which makes this knock **stricter** than the existing
Shell-Core-carried arm-B promotion (pinned in `ClassicAwaitingHelloGuardTests`, which
requires no IME or desktop evidence at all). Hello semantics ride the existing rails:
enabled-or-unknown promotes to `AwaitingHello` with `HelloSafety` armed (on Cloud PCs
reached via RDP the Hello wizard never appears, so the synthetic timeout resolves the
wait), disabled completes directly with the synthetic `Skipped` outcome.
The knock deliberately lives on the IME user-session edge, not on desktop arrival:
* **Ordering-robust against Fix 10.** An `EspPhaseChanged(AccountSetup)` arriving on
`AwaitingHello` bounces the stage back and cancels `HelloSafety` (the premature-promotion
guard). The IME AccountSetup phase line lands *after* the desktop on this flow, so a
desktop-side knock would be undone two signals later. The IME phase line always precedes
the user-session-complete line in log order, so a bounced promotion is re-knocked by the
very next re-emission — including the restart re-parse.
***Right semantics.** The IME user session completes when the required-app processing is
done; a desktop-side knock would mark the session `Succeeded` mid-installation.
* Device Preparation is excluded — WDP keeps its Hello+Desktop conjunction and the
`DevicePrepCompletion` backstop.
`ClassicSkipUserEspUserSessionCompletionTests` replays the real `8110e262` signal ordering
end to end (bootstrap `EspConfigDetected` → Hello enabled → desktop → AccountSetup anchor →
IME completion → HelloSafety → `enrollment_complete`), including the Fix-10
bounce-then-re-knock cycle, and pins the negative space (no skip observed / no desktop /
ghost pre-anchor IME completion / WDP).
Why five of seven identically administered Cloud PCs carried `SkipUserStatusPage=True` is
a tenant-side question (ESP profile assignment state at each machine's provisioning
moment); the value did not change during any observed session, and machine age does not
cleanly explain the split. The agent's job is to complete the flow Windows actually ran —
which it now does.
## Adjacent defect — the synthesis was edge-triggered
This one is real but belongs to the `SkipUser=false` flow (the two sessions that
succeeded). The user-apps-settled synthesis
(`MaybeSynthesizeAccountSetupCompleteFromSettledUserApps`, session `caa6cf50`) ran **only**
from `OnEspExited`. On a tenant with ~138 required apps the ESP page exits while apps are
still in flight: the single attempt misses and nothing ever re-checks, even when every
tracked app reaches a terminal state minutes later.
The ESP exit is an **edge** (happens once); settled user-ESP apps are a **level** (reached
later). `EspAndHelloTracker` records the edge in `_espExitObserved` and exposes
`ReevaluateUserAppsSettledSynthesis()`, chained into `ImeLogTracker.OnAppStateChanged` and
into the post-restore `onStateRestored` callback (a live exit observed before the IME
state restore completes would otherwise never get a second look). Gate conditions are
unchanged — no new completion path, only a second opportunity for the existing one. Only a
**live, confirmed post-AccountSetup** exit may be remembered
(`IsConfirmedPostAccountSetupExit`, stricter than the `IsIntermediateDeviceEspExit`
forward guard: unknown counts as not confirmed).
## Adjacent defect — the restart replay was never wired up
`ShellCoreTracker`'s backfill had existed since session `772fe502` with **no caller**; the
Shell-Core `EventLogWatcher` only delivers records written after `Start()`.
`EspAndHelloHost.Start()` now calls it as `BackfillRecentHelloWizardStart` — which is
exactly what it does and all it does:
| Record | Replayed | Why |
|---|---|---|
| 62404 Hello-wizard start | **yes** | Conservative: vetoes a premature "Hello is disabled" skip, can never complete a session by itself. The observation `772fe502` was about. |
| 62407 ESP exit | **no** | Cannot be placed in time — see below. |
| 62407 ESP failure | **no** | Re-injecting a historic failure as fresh can fail a session that recovered on retry (`ANALYZE-ESP-006`). |
A replayed exit is unusable, and every candidate ordering mechanism fails independently:
1. Windows writes the **identical** description `CommercialOOBE_ESPProgress_Page_Exiting`
for the intermediate DeviceSetup→AccountSetup transition and the final exit.
2. Every post-hoc ordering probe (AccountSetup registry, settled-apps) reads state as it
is **now**, not as it was at the event's time.
3. The reducer orders exits by **ingest ordinal**, not timestamp
(`IsPostAccountSetupFinalExit`) — a historic exit replayed today gets a fresher ordinal
than reality and reads as post-AccountSetup by construction.
4.`HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit; with
restored state, arm C would open on a historic intermediate exit.
`ClassicEspExitingOnRestoredStateTests` pins that the reducer *does* open on any arriving
exit; `ShellCoreTrackerReplayScopeTests` pins that the replay never produces one. Skipped
62407 records are counted and reported once as an `agent_trace`
(`reason: replayed_62407_not_orderable`) — silently dropping evidence is how this class of
bug survives.
**Residual gap, named honestly:** a `SkipUser=false` session whose final exit falls
exactly into an agent downtime *and* whose AccountSetup category never resolves
(`1ec8f4c6` shape) still has no completion carrier after the restart. The normal case is
covered — `ProvisioningStatusTracker` reads the current registry at startup, so a
category that resolved during the downtime fires arm A. The residual shape is rare,
surfaced by `ANALYZE-ESP-005` via the `session_stalled` interim trigger, and deliberately
not patched with a replay that cannot be ordered.
### Sizing the replay window
A constant is provably wrong: the scheduled task has a `BootTrigger` only
(`Program.InstallMode.BuildScheduledTaskXml`) — after an `exception_crash` the agent does
not return until the next boot. `ResolveEspExitBackfillLookbackMinutes` reaches back to
the last moment the previous run is known to have been alive (max of `snapshot.json`
mtime and `LastBootUtc` for kill-type exits), floored at 5 minutes, capped at 360, off on
`first_run`, and immune to cross-reboot clock skew (a future timestamp is ignored).
# Examples
Tenant `sits-d.cloud`, 2026-08-19, seven Windows 365 Cloud PCs, first sign-ins the same
morning. The discriminator is a single registry value:
description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
title: Secure Boot CA-2023 Detection — firmware evidence vs. Windows servicing status
description: Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly (GetFirmwareEnvironmentVariableExW + SeSystemEnvironmentPrivilege) instead of trusting the SecureBoot\Servicing registry key, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant rule suppression across old and new agents.
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
description: How the radar detects app versions whose median install duration regressed vs. the previous version (median lift ≥2x plus an absolute floor over first-seen-ordered versions), how alert episodes live in the appversionregression tracker keyspace, and which surfaces show them.
title: Business Timestamps vs. the Azure Tables System Timestamp
description: Why the system Timestamp is never authoritative, how the 2026-07-18 migration exposed that, and the OccurredUtc + RowKey-decode compensation (mappers, RowKey-range date filters, retention, backfill).
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them.
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
## 1. Table lifecycle buckets
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
| Bucket | Mechanism | Source of truth |
|---|---|---|
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
## 2. SessionsIndex field manifest
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
- Non-global callers always keep the middleware-validated `TargetTenantId`.
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
-`requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
## 4. Server-side dashboard search
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
# Examples
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
description: How the radar detects analyze rules whose hit rate suddenly rose (7-day window vs 28-day baseline, Wilson-separated), which suppressions keep it quiet, how alert episodes live in the notification tracker, and which surfaces show them.
description: The queue-on-session, deliver-on-ingest ServerAction channel (terminate_session / rotate_config / request_diagnostics), the operator-tier authorization split, the portal's Collect Logs flow including the admin quick-config path, and why the offboarding cascade sweeps the hosted diagnostics prefix.
title: Session Annotations — Role-Separated Human Verdicts
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
description: How a terminal session's wall clock is partitioned into named segments with an explicit unattributed remainder, how ESP-blocking apps and reboots are attributed, and where breakdowns and fleet aggregates are computed, stored and served.
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [CMTrace Time Resolution](agent/cmtrace-time-resolution.md) - Converting bias-less CMTrace local times to UTC without trusting any process's timezone belief: per-line self-anchoring on the 15-minute offset grid for provably fresh lines, uniform marked reader-zone fallback for backlog, and the provenance vocabulary (bias / line-anchored / reader-zone-fallback) that keeps every event re-derivable.
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
* [Analyze Rule Evaluation Triggers](rules/analyze-rule-triggers.md) - Opt-in `evaluateOn` interim triggers (`whiteglove_sealed`, `on_event:<type>`) so rules can fire before a session is terminal, with RuleResults update-semantics (interim → finalize/resolve, NotifiedAt notification dedupe) replacing the permanent dedupe freeze, KO/stat suppression on interim runs, the ingest-side cached trigger registry, and the authoring lints that keep interim rules monotonic.
# Backend
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
# MCP Server
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
# Web Portal
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
# Conventions for this bundle
* Every concept document is a markdown file with YAML frontmatter; `type` is mandatory, `title`, `description`, `resource`, `tags`, and `timestamp` are recommended.
* Use standard **relative** markdown links between documents (e.g. `agent/overview.md`, `../architecture.md`). Do NOT use the spec's `/`-prefixed bundle-absolute form — GitHub resolves those against the repo root (missing `/docs`) and navigation breaks.
* Record notable additions and changes in [log.md](log.md).
*`index.md` and `log.md` are reserved names — never use them for concept documents.
* **New**: `agent/cmtrace-time-resolution.md` — the era-aware answer to the CMTrace timezone skew: a bias-less line read in a provably fresh pass anchors its own UTC offset against the agent clock (15-minute grid, 2-minute residual), so interleaved writer eras in one file resolve line by line; everything not provably fresh keeps the uniform reader-zone fallback and says so via `sourceOffsetOrigin`. Freshness is a per-file `LastCheckedUtc` stamped on every poll look (empty first sights included) with a 30-second window, never restored across restarts. Replaces the reverted per-file offset application (04b1a7c6); the per-file measurement stays observational, now with the anchor timestamp logged as a tripwire for the still-unexplained e9753578 label/anchor crossing.
* **Update**: `agent/post-reboot-completion-recovery.md` — retitled to "ESP Completion Starvation — the sits-d Cloud-PC complex" and reframed on the evidence: the reboot was a red herring. The five stuck Cloud PCs carried `SkipUserStatusPage=True` (read 10+ times across three agent processes, `True` every time) and Windows honoured it — no user ESP page, hence no Shell-Core 62407 and no AccountSetup provisioning registry, verified over a six-hour uninterrupted post-reboot run with the watcher armed. Arm B of `ShouldTransitionToAwaitingHello` was open from the bootstrap onward, but every evaluation site hangs off a signal a skip-user flow cannot produce, and the `AdvisoryCompletion` backstop arms exclusively from an `EspExiting` signal — an open gate nobody knocks on. Fix: `HandleImeUserSessionCompletedV1` accepts the observed skip in place of the final-exit evidence (`skipUserExitEquivalent`; anchor + genuine IME completion + real-user desktop stay mandatory, WDP excluded). The knock deliberately lives on the IME edge, not desktop arrival — ordering-robust against Fix 10's AccountSetup bounce-back and semantically "user-phase work finished" rather than mid-install. `ClassicSkipUserEspUserSessionCompletionTests` replays the real 8110e262 ordering end to end. Docs and comments no longer claim an ESP-exit recovery that does not exist (stray duplicate summary in `ShellCoreTracker`, `ImeLogHost` restore-callback comment); the residual `SkipUser=false` exit-in-downtime gap is named honestly and surfaced by `ANALYZE-ESP-005` via `session_stalled` instead of being patched with an unorderable replay.
## 2026-08-19
* **New**: `agent/post-reboot-completion-recovery.md` — a forced mid-ESP reboot (DeviceGuard/DmaGuard) used to cost the session its AccountSetup completion permanently. Two defects: `ShellCoreTracker.BackfillRecentEspExitEvents()` existed but had no caller, so every agent restart dropped an ESP exit (62407) written while it was down; and the user-apps-settled synthesis was edge-triggered on `EspExited` only, so apps settling afterwards never re-opened the gate. `EspAndHelloHost.Start()` now backfills with a lookback sized to the downtime (`previousExit=reboot_kill` → since `LastBootUtc`, clamped to 360 min), and `ReevaluateUserAppsSettledSynthesis()` is chained into `ImeLogTracker.OnAppStateChanged`. No new completion path — the gate conditions are unchanged. Evidence: sits-d Cloud PCs, five `Incomplete` sessions on healthy devices, two of them at `138/138 apps, 0 failed`. Hardened over two review rounds: the remembered edge must be a **live** exit that is **confirmed** post-AccountSetup (a replay carries no evidence of its own position — Shell-Core writes the same `CommercialOOBE_ESPProgress_Page_Exiting` for the intermediate and the final exit, and the registry check reads *now*, not the event's time); the replay hands over the **newest** exit rather than the oldest; the replay is narrowed to the Hello-wizard start (62404) alone — a conservative fact that can only make the agent wait longer. A replayed ESP exit is never re-raised at all: Windows writes the identical description for the intermediate and the final exit, the reducer orders exits by ingest ordinal (fresher than reality for a replay) and `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit, so arm C of `ShouldTransitionToAwaitingHello` would open on a historic handoff once the restored state carries AccountSetupEntered + IME user session + desktop. A replayed ESP failure is likewise dropped (it could fail a session that recovered). Skipped records are reported as an `agent_trace`. The window is **measured** from the previous run's last known liveness (snapshot mtime, widened by `LastBootUtc`) instead of a constant — the scheduled task has a BootTrigger only and no restart-on-failure, so a crashed agent returns hours later and five minutes would lose the exit every time. On `first_run` the replay stays off, leaving the happy path untouched.
* **Update**: `rules/analyze-rule-triggers.md` — `on_event:session_stalled` documented as an established interim trigger for the never-terminal stuck session (one emission per session, no throttle needed). `ANALYZE-ESP-005` carries it: both its conditions are monotonic and it has no terminal precondition, and it fires at confidence 95 on the sits-d stalled stream. `ANALYZE-APP-015` deliberately does NOT — it is the worked example of the terminal-precondition trap (lint 1) — and `ANALYZE-ESP-001` does not either, because its `phase_duration` gate on a still-open phase is unverified mid-run. New `ANALYZE-APP-016` ("Required Apps Never Started — ESP Apps Gate Starved") turns the existing `app_install_starved` events into a customer-facing finding and, unlike `ANALYZE-APP-015`, fires even when the session succeeded — session `3b9291aa` was recorded `Succeeded` with 99 required apps that never started installing.
* **New**: `script-publishing.md` — the customer script publishing chain. `.github/scripts/Publish-BootstrapScripts.ps1` becomes the single owner (dev render, version-bump guard, uploads, version oracles, alias verification), called both by the new `publish-scripts.yml` (push to `main` on `scripts/Bootstrap/**` and `scripts/CustomerSetup/**`) and by the `publish_as_stable` branch of `build-agent.yml`. Script publishing no longer waits for an agent release. Blobs are published in a deterministic CRLF/UTF-8-no-BOM form so byte comparisons do not depend on the checkout; the Pester + ASCII gates move into the reusable `bootstrap-script-gates.yml` and block the upload; `AdminConfiguration.LatestBootstrapV2ScriptVersion` now has exactly one writer. The version-bump guard compares a code fingerprint (PowerShell token stream without comments, newlines and line continuations), so only behaviour changes require a `$ScriptVersion` bump — comment and formatting changes publish with a warning.
## 2026-08-18
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
## 2026-08-17
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
## 2026-08-14 (later)
* **Update**: `rules/analyze-rule-triggers.md` — status SPEC → IMPLEMENTED. Shipped: `evaluateOn` on the analyze-rule schema (grammar-validated server-side and in validate_rule), `AnalyzeRunContext`-driven engine runs (Terminal/Interim/Reanalyze) with the RuleResult lifecycle columns (FirstDetectedAt/LastEvaluatedAt/IsInterim/ResolvedAt/NotifiedAt), interim rule filtering + KO/stat suppression, resolve/finalize update-semantics, the NotifiedAt one-send-per-(session,rule) notification dedupe (preserved across the manual reanalyze rebuild), the ingest-side `InterimTriggerRegistry` (5-min TTL, fail-soft) gating `interim_trigger` / `whiteglove_sealed` enqueues, the three MCP authoring lints, web preliminary/resolved rendering, and ANALYZE-ID-004 as the first interim-enabled rule.
## 2026-08-14
* **Creation**: Added `rules/analyze-rule-triggers.md` — SPEC for opt-in `evaluateOn` interim evaluation of analyze rules: `enrollment_end` default preserves today's behavior, `whiteglove_sealed` + `on_event:<type>` close the WhiteGlove notification gap and the never-terminal stuck-session gap; RuleResults update-semantics (FirstDetectedAt/IsInterim/ResolvedAt/NotifiedAt) replace the permanent dedupe freeze; interim runs suppress KO + stats, notifications fire on first detection; three new validate_rule lints (terminal-precondition trap, unknown trigger type, KO-on-interim).
## 2026-08-13 (late evening)
* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet.
* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published.
## 2026-08-13 (evening)
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
## 2026-08-13
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
## 2026-08-12
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
## 2026-08-11 (later)
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
## 2026-08-11
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
## 2026-08-10
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
## 2026-08-06
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
## 2026-08-05 (later)
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
## 2026-08-05
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
## 2026-08-04
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
## 2026-08-02
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
## 2026-07-29
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#:`BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts``/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
## 2026-07-27
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
## 2026-07-24
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
* **Creation**: Added `agent/secureboot-ca2023-detection.md` — ANALYZE-SEC-001 warned on devices whose firmware already carried the 2023 certificate, because the agent only read the `SecureBoot\Servicing\UEFICA2023Status` registry value, which mirrors the Windows Update rollout rather than the firmware (customer report). The agent now reads the UEFI `db`/`KEK` variables natively (`GetFirmwareEnvironmentVariableExW` with `SeSystemEnvironmentPrivilege` enabled on the process token — first privilege-adjustment code in the repo) and emits per-certificate booleans on `secureboot_status`. Suppression rides on a one-sided marker, `uefiCa2023FirmwareConfirmed`, emitted only on positive confirmation: the engine's `not_exists` precondition fails on any present value, so a two-sided boolean could not distinguish "confirmed" from "checked and missing", while the marker's absence keeps old agents and firmware-missing devices on the v2 path. Rule v3.0.0 is therefore safe to seed before the agent release.
* **Update**: `url-registry.md` — the first release after the alias migration surfaced Front Door caching as a correctness hazard: the alias served the previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) after the origin had rotated, and since manifest and ZIP are independent cache entries, a stale manifest with a fresh ZIP fails the bootstrap SHA-256 check. Route caching on `/agent/*` is now disabled and every blob upload stamps `Cache-Control: no-cache` (CI `Send-Blob`, local `build.ps1`, `versions/*.json` manifests) — all agent-container blobs rotate in place, so nothing there is safely cacheable.
* **Update**: `agent/decision-engine.md` — the RealmJoin completion gate has a third terminal, `FirstDeploymentIncomplete`. Session 224b2087 showed why: an interactive logon 17 s after DeploymentPhase 101 makes RealmJoin reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never 110 — the gate stayed closed until the 60-min hard timeout although Hello and desktop were long resolved. `realmjoin_phase_changed` is now a typed `RealmJoinPhaseChanged` signal, so `RealmJoinFacts.LastDeploymentPhase` is persisted across agent restarts (this also fixes every `realmjoin_timeout` event claiming "last phase: 0"); a transition out of 100/101 into 200/210 releases the gate with the `realmjoin_first_deployment_incomplete` Warning, and the `RealmJoinDetected` replay path evaluates the same rule for restarts that swallow the transition. A first observation of 210 deliberately does not release — session 6f1959c0 started at 210 and only deployed afterwards.
* **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301.
## 2026-07-22
* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`<major>.<minor>.<build>` + 7-char commit + build timestamp) and one implementation of the build-number reservation (`.github/scripts/Request-BuildNumber.ps1`, previously copy-pasted between `build-agent.yml` and `build.ps1`). The backend had been pinned at a hand-written `1.0.0` since inception and now starts at `1.5.1000`; the MCP server's `package.json` version was bumped by hand and now starts at `1.5.300`, and it reports its own commit on `/health` instead of only the documentation bundle's. Both deploy workflows poll their health endpoint until it reports the build just deployed and publish `versions/<component>.json` only then, so a manifest that disagrees with the live endpoint means the instance drifted rather than the pipeline failing halfway. `agent/build-counter-blob.md` keeps the agent-specific parts and now points at the shared protocol; its counter URL was still the pre-cutover storage account.
* **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout.
* **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests``[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load.
## 2026-07-21
* **Update**: `search_docs` and `search_knowledge` now state in their tool descriptions that queries must be in English. Both corpora and `all-MiniLM-L6-v2` are English-only, and the failure is total rather than gradual — a German question returns zero results, not weak ones. The caller is a language model, so translating the question before the call and answering in the user's language costs nothing; a multilingual embedding model would be the alternative but is much larger and would have to be measured against the eval harness first, since it changes English retrieval too.
* **Update**: Added `scripts/eval-docs-search.ts`, an offline retrieval evaluation harness (2465 queries in three classes: chunk headings as a regression detector, rare identifiers, and hand-written questions as the reality anchor), and used it to close a third `search_docs` gap found during the live test. The keyword fallback only fired when semantic search returned *fewer* than `topK`; a pasted identifier like `WOW6432Node` instead returned three chunks at ~0.29 that did not contain the word while the one chunk that did was never considered. It now also fires on a weak semantic head (<0.35), letting exact matches take the front while keeping one semantic result. The threshold is measured, not guessed: 0.35 fixes 335 queries and breaks 0 (identifier recall 66.2% → 81.1%), while 0.40 fixes more but starts demoting hand-written questions. The measurement also resized the problem — identifier queries were only succeeding 66.2% of the time, so the reported case was one of roughly 750.
* **Update**: Closed the two measured recall gaps in `search_docs` (`mcp/docs-corpus.md`). (1) **Token windows** — 87 of 324 chunks exceeded the model's 256-token context and 13% of all tokens never reached the ranking; a character budget cannot fix this (the corpus ranges 2.43–5.81 chars/token), so long text is now embedded as 250-token windows with 40 overlap and scored by its BEST window (`embeddings: number[][]`, max not mean). Token coverage 87% → 100%; "proxy configuration blocking app downloads" went from absent to 0.559 at rank 1; the ten reference queries kept byte-identical scores. Costs +31% vectors, index 2.63 → 3.12 MB. The rules corpus gained the same treatment (128 rules → 172 vectors). (2) **Keyword fallback** — when the semantic pass returns fewer than `topK`, results are topped up via literal `lexicalMatch` on distinctive query terms, requiring two matching terms for corroboration (one incidental substring match returned a page about LLM providers for a sourdough query). Fuzzy matching was measured and rejected: at a threshold low enough to surface "RealmJoin" it invents 5 hits for "SCEPman", which the corpus never mentions. Also sharpened the tool description with the measured context argument for preferring it over fetching the docs site.
* **Creation**: Added `mcp/docs-corpus.md` (new `mcp/` section) — the MCP server gains `search_docs`, a third semantic corpus built from the published customer documentation repository (`autopilotmonitor-docs`, checked out into the Docker build context by the deploy workflow). Documents the h2/h3 + `<details>` chunking with breadcrumb prefixes, the two deliberate size exceptions (tables never split, unbroken changelog lists split at line level), the CRLF precondition (`.` does not match `\r` in JS — headings silently stop matching, which produced 286 wrong-but-plausible chunks before it was found), the optional `docs` section in `search-index.json` and its validation asymmetry, embedding rounding to 6 decimals, and the cross-repo coupling: a docs edit reaches clients only after an MCP redeploy, made checkable via `DOCS_COMMIT` on `/health`. Container resources raised to 0.5 vCPU / 1.0 GiB for the cold-start query-embedder load.
## 2026-07-20
* **Update**: `rules/gather-rule-phase-scoping.md` — the gather-rule form now shows the phase-scope and emit-mode controls only where they can change behavior (`supportsPhaseScope` / `supportsEmitMode`), nulls hidden-control state out of the payload, and rejects a scope mode selected but left unfilled. Offering all three "when" controls unconditionally allowed combinations that could only suppress a one-shot rule, plus a silent no-op state.
* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
* **Creation**: Added `backend/business-timestamps.md` (new `backend/` section) — the 2026-07-18 migration reset every row's system Timestamp, exposing that AuditLogs/OpsEvents/Events had (unknowingly) displayed and filtered on it all along; documents the `OccurredUtc` column, the RowKey decoders and RowKey-range date/retention clauses in `BusinessTimestamp`, the GUID-guard trap, the audit/ops backfill endpoint, and the "system Timestamp is never authoritative" migration rule.
* **Creation**: Added `rules/gather-rule-phase-scoping.md` (new `rules/` section) — gather rules gain `activePhases` (run only during listed phases), `activeFromPhase` (sticky latch from a phase onwards, never via Failed) and `emitMode: "on_change"` (canonical-hash result dedup with `suppressedPolls` observability), ConfigVersion 34. Includes the prerequisite backend fix: the custom-rule toggle PUT no longer wipes the stored rule definition (partial-merge in `GatherRuleService.UpdateRuleAsync`).
## 2026-07-18
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
## 2026-07-26
* **Creation**: Added `backend/time-attribution.md` — the F1 enrollment time attribution: exact-partition invariant against the session's authoritative DurationSeconds, two-window WhiteGlove model, segment mapping from phase-declaration events, positive-evidence ESP-blocking join with "up to" what-if bounds, reboot spans from event gaps around `lastBootUtc`, and the SessionTimeBreakdowns / TimeAttributionAggregates tables with their terminal-seam + maintenance-sweep compute paths and read surfaces (web lane, fleet panel, MCP `get_time_attribution`).
## 2026-07-17
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
## 2026-07-16
* **Creation**: Added `agent/autopilot-ztd-diagnostics.md` — reference for Windows' own ZTD diagnostic surfaces (ModernDeployment Autopilot event IDs incl. 807/809/815/908, the `Diagnostics\Autopilot` registry key, deployment-service endpoints, known-issue error-code map with KB fixes). Backs the agent's `ZtdEvidence` collector (`ztdVerdict` on `autopilot_profile_missing`) and the backend known-issue rules; sources carry a re-check RSS feed.
## 2026-07-13
* **Removal**: Moved `customer-graph-add-on-permissions.md` out of this contributor bundle into the customer documentation repository (`reference/optional-graph-permissions.md`). It was customer-facing product content and did not belong in the tech-docs bundle; removed the entry from `index.md`.
* **Creation**: Added `agent/hello-wizard-unskip.md` — session 772fe502 fix: the `HelloWizardStarted` decision signal (prevention veto + un-skip cure for the policy-disabled Hello shortcut) and the HelloTracker confirmation second read against flip-flopping WHfB CSP values.
## 2026-07-11
* **Creation**: Added the `agent/` section — three concept documents on the V2 agent: `agent/overview.md` (runtime, collectors, telemetry pipeline), `agent/decision-engine.md` (DecisionCore reducer, signals, A/B/C completion arms, invariants), `agent/logs-and-persistence.md` (persisted artifacts, signal log vs spool, recovery/replay flow) plus `agent/index.md`.
* **Creation**: Converted `docs/` into an OKF v0.1 knowledge bundle — added YAML frontmatter to `architecture.md`, `mcp-oauth-flow.md`, and `customer-graph-add-on-permissions.md`; created `index.md` and this log.
description: How the two unrelated identities (AI-product account vs. Entra ID work account) and three parties interact when connecting an AI client to the Autopilot Monitor MCP server.
title: MCP Docs Corpus — search_docs and the Cross-Repo Build Coupling
description: How the published customer documentation becomes a third semantic search corpus in the MCP server — CRLF-safe heading chunking, build-time embedding, the corpus hash guard, and why editing the docs does not update a running server.
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
description: "Opt-in interim evaluation of analyze rules before a session is terminal — enrollment_end stays the default, whiteglove_sealed and on_event:<type> triggers close the WhiteGlove notification gap and the never-terminal stuck-session gap with one mechanism, backed by update-semantics on RuleResults instead of the permanent dedupe freeze."
**IMPLEMENTED 2026-08-14** (same day the spec was approved). The open product decisions below were confirmed as "no" for v1. Key implementation anchors: `AnalyzeRuleTriggers` (Shared — grammar + matching helpers, shared by engine, ingest registry and CRUD validation), `AnalyzeRunContext` / `AnalysisOutcome.ResolvedResults` (RuleEngine), `InterimTriggerRegistry` (5-min-TTL per-tenant cache for the ingest hot path, fail-soft), `AnalyzeOnEnrollmentEndHandler` interim branch, `EvaluateOnJson` + five RuleResult lifecycle columns in `TableStorageService.Rules`. ANALYZE-ID-004 ships as the first interim-enabled rule (`on_event:hybrid_login_pending`, repetition-gated at threshold 75).
# Problem
Analyze rules run at exactly three points today, all of them terminal or near-terminal:
2. The maintenance sweep terminalizing a silent session (Failed / Incomplete + synthetic `session_timeout`, `MaintenanceService.cs`).
3. The vulnerability-correlation rerun (`vulnerability_correlated` reason — incremental, no stats).
Two incident classes fall through this model:
- **WhiteGlove gap (sits-d, 2026-08-11):**`whiteglove_complete` seals a session to Pending but enqueues no analyze run. A rule that should alert at the end of pre-provisioning (technician still at the bench) never fires, and when Part 2 eventually terminalizes days later, the permanent per-(session, rule) dedupe means a finding that sneaked in earlier (e.g. via the vuln-rerun backdoor) is frozen and never notifies.
- **Never-terminal stuck sessions (esa-logistics, 2026-08-13/14):** a session looping in Account Setup (user signs in daily, hybrid affinity never establishes) resets the 5h inactivity clock on every logon. It is never terminalized, therefore never analyzed — the exact sessions where a diagnosis would help most (ANALYZE-ID-004, ANALYZE-APP-015) produce no finding while the customer watches the device loop.
Both gaps share one root cause: *evaluation time is hard-wired to session end*. Gather rules already solved the analogous problem on-device with declarative triggers (`startup` / `phase_change` / `phase_exit` / `interval` / `on_event`); analyze rules need the server-side equivalent.
# Design Overview
Add an optional `evaluateOn` array to the analyze-rule schema. Absent field ⇒ `["enrollment_end"]` ⇒ **today's behavior, bit-for-bit, for every existing rule**.
| `enrollment_end` | terminal analyze run (complete / failed / sweep-terminalized) | default; also the **finalization pass** for interim results (below) |
| `whiteglove_sealed` | ingest classifies the first genuine `whiteglove_complete` (session → Pending, `isPreProvisioned`) | duplicate seals do not re-trigger |
| `on_event:<eventType>` | an ingest batch contains ≥1 event of `<eventType>` | `<eventType>` validated against the event-types catalog; phase boundaries are covered by `on_event:phase_transition` / `on_event:esp_phase_changed` — no separate phase grammar needed, since every run evaluates the full stream anyway |
Explicitly **not** in v1: interval/timer triggers (no cheap host for them; the sweep remains the only clock), and `on_event` filters on data fields (the rule's own conditions do that — the trigger only decides *when* to look).
# Pipeline Changes
## Ingest (producer side)
- `EventIngestProcessor` already classifies each stored batch. After classification, intersect the batch's event types with the tenant's **trigger registry** (the set of `on_event` types across the tenant's active rules — derivable from `AnalyzeRuleService`'s merged rule load, cached alongside it). Non-empty intersection ⇒ enqueue one envelope `Reason=interim_trigger`, carrying the matched trigger event types. Reuse the existing 30s visibility delay (terminal-flush ordering argument in `AzureQueueAnalyzeOnEnrollmentEndProducer` applies to interim batches too).
- The `whiteglove_complete` seal path (`EventIngestProcessor.Classification.cs`) enqueues `Reason=whiteglove_sealed` — only on the first seal, only for `isPreProvisioned`.
- Terminal enqueue (`CompletionEvent` / `FailureEvent`) is unchanged.
## Handler / engine (consumer side)
`AnalyzeOnEnrollmentEndHandler` branches by reason today (primary vs. `vulnerability_correlated`); interim reasons become a third branch. The engine call gets a run context instead of the bare `reanalyze` flag:
| Aspect | Terminal run (`enrollment_end`) | Interim run (`whiteglove_sealed`, `interim_trigger`) |
|---|---|---|
| Rule set | ALL active rules (interim-triggered ones get their finalization pass here) | only rules whose `evaluateOn` matches the reason / one of the batch's trigger types |
| KO (`MarkSessionAsFailed`) | applies (unchanged) | **suppressed** — a Pending/InProgress session must never be flipped Failed by an interim pass (`TryMarkSessionFailedFromRuleAsync` is skipped; the terminal run re-fires the rule and applies KO then) |
| Per-rule fire stats | counted | skipped (like vuln rerun) — keeps the terminal-only stats convention and the Rule Regression Radar baselines clean |
| Channel notifications | new findings notify (unchanged) | new findings notify — **this is the point** of both gaps |
| SignalR results-available | yes | yes (live update of an open session page) |
## Result lifecycle: update semantics replace the dedupe freeze
Today the engine skips any rule that already has a stored `RuleResult` row for the session (`RuleEngine.AnalyzeSessionAsync`, existing-results lookup) — findings are frozen at first write, which is precisely the WG-gap failure mode ("dedupe permanent").
New model — `RuleResult` gains four fields (`FirstDetectedAt`, `LastEvaluatedAt`, `IsInterim`, `ResolvedAt`, plus `NotifiedAt` for the notification marker):
- **Interim run:** rules in scope are (re-)evaluated even when a row exists. Fired ⇒ upsert (evidence/confidence refreshed, `FirstDetectedAt` preserved, `IsInterim=true`). Not fired + existing interim row ⇒ `ResolvedAt` set (kept for audit; excluded from issue counts and default UI).
- **Terminal run:** every rule that has an interim row is re-evaluated regardless of dedupe (finalization). Fired ⇒ row finalized (`IsInterim=false`, evidence refreshed — fixes the stale-template-field freeze, e.g. the ESP-004 `{{appName}}` case). Not fired (typically a `not_exists` precondition on `enrollment_complete` now failing because the session healed) ⇒ `ResolvedAt` set. Rules without interim rows keep today's skip-if-exists dedupe.
- **Notification dedupe** moves off "row exists" onto `NotifiedAt` on the row: notify once per (session, rule) at first fire — interim or terminal, whichever comes first; refreshes and finalization do not re-notify. The manual `reanalyze=true` path must **preserve**`NotifiedAt` across its rebuild (today it deletes rows; delete-and-recreate would silently re-arm notifications) and keeps its designed never-notify behavior.
- Legacy rows (null new fields) read as final/terminal — no migration.
# Authoring Guardrails (validate_rule lints)
Interim evaluation changes what a rule's conditions mean mid-flight. Three new lints in the MCP `validate_rule` pre-flight (and the baked authoring guide):
1. **Terminal-precondition trap (warning):**`evaluateOn` contains an interim trigger AND a precondition is `not_exists` on `enrollment_complete` / `enrollment_failed` / `session_timeout`. Mid-run these pass trivially — the rule needs conditions that are *monotonic* (once true on the growing event stream, stays true) or repetition-gated. Worked example: ANALYZE-APP-015 must NOT get an interim trigger as-is — even successful sessions transiently show its "completion signal deferred" + `pending>=1` pattern (the terminal precondition is its only gate). ANALYZE-ID-004 is interim-safe by construction (threshold 65 requires repeated `hybrid_login_pending`).
2. **Unknown trigger event type (error):**`on_event:<type>` not in the event-types catalog.
3. **KO-on-interim (warning):**`markSessionAsFailedDefault: true` on a rule whose only triggers are interim — the KO would never apply until terminal; say so.
# Cost & Safety
- Interim runs are bounded by trigger frequency, and the chosen v1 triggers are rare by construction (`hybrid_login_pending` ≤1 per boot, `whiteglove_sealed` once per session). High-frequency telemetry event types (performance_snapshot, agent_metrics_snapshot, download_progress, network churn, log_entry, agent_trace, stall_probe_check) are **hard-blocked** as `on_event` triggers, not merely advised against: the CRUD write is rejected (`AnalyzeRuleTriggers.BlockedOnEventTypes`, code-only enforcement), the runtime matcher drops them from any row that slipped in (defense in depth — the trigger registry and the engine both go through `OnEventTypes`), and the MCP lint reports them as an error. The JSON mirror lives in `rules/guardrails.json` (`blockedInterimTriggerEventTypes`) with a parity test pinning it to the C# set — the same pattern the gather hard blocks use. If a legitimate rare-but-bursty type ever hurts, a per-(session, reason) throttle marker is the v2 lever.
- Each run loads the full event stream once (existing engine behavior, no change); rule filtering makes interim passes strictly cheaper than terminal ones.
- Queue semantics (retry via visibility timeout, poison after MaxDequeueCount, idempotent partial-success re-runs) are inherited unchanged; upserts keep idempotency.
# Surfaces
- **Web session page:** interim findings render live (existing SignalR results-available notification) with a "preliminary" badge on `IsInterim`; `ResolvedAt` rows hidden by default behind a toggle.
- **MCP:**`get_session(includeAnalysis)` / `get_session_summary` expose `isInterim` / `resolvedAt`; `list_session_annotations`' fired-rule snapshot semantics unchanged (labels bind to whatever was fired at annotation time).
- **Success-rate / platform stats:** terminal-only convention untouched (interim runs record no stats).
# Application to the Motivating Cases
- WG battery rule (sits-d): `evaluateOn: ["enrollment_end", "whiteglove_sealed"]` — technician gets the channel alert at the bench; Part-2 terminal pass finalizes or resolves the finding.
- Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1).
- Stuck after a forced mid-ESP reboot (sits-d Cloud PCs, 2026-08-19): `on_event:session_stalled` is the established trigger for the never-terminal stuck session. The agent emits `session_stalled` exactly once per session (60-minute stall probe), so the trigger is rare by construction and needs no throttle. Five Cloud PCs sat at `InProgress` with **zero findings** for hours — a mid-ESP reboot had killed the agent in `Stage=EspAccountSetup` — before the max-lifetime watchdog eventually terminalised them as `Incomplete`. ANALYZE-ESP-005 carries the trigger and fires on that stream at confidence 95 (verified against session `8110e262`): both its conditions (`mdm_policy_reboot_required.firstRebootUri` exists, `system_reboot_detected` count ≥ 1) are **monotonic** and it has no terminal preconditions, so lint 1 does not apply. ANALYZE-APP-016 is interim-safe for the same reason (`app_install_starved` count ≥ 1 only ever grows).
Two rules were considered and deliberately **not** given the trigger: ANALYZE-APP-015 is the worked example of lint 1 above and stays `enrollment_end`-only; ANALYZE-ESP-001 gates on `phase_duration` of a phase that is still open mid-run, and that evaluation is not pinned by a test — an unverified trigger is not worth a possible false positive on every slow DeviceSetup.
# Product Decisions (settled 2026-08-14)
1. Notify-on-resolve (a "finding healed" message)? **No** (v1) — resolution is visible in UI/MCP.
2. Re-notify on severity/confidence escalation of an existing finding? **No** (v1) — one notification per finding.
3. `whiteglove_sealed` runs allowing KO rules to flip Pending → Failed? **No** — the Pending state is the WhiteGlove contract; suppression is unconditional for interim.
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
# Schema
Allowlists come from `rules/guardrails.json`, embedded into the agent assembly as
`AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json` (see the `EmbeddedResource`
item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal's
`utils/guardrails.generated.ts` by `node rules/scripts/combine.js`.
| Guard | Source list | Match rule |
| --- | --- | --- |
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | `SELECT <* \| property-list> FROM <class>`; class = case-insensitive equality against classes derived from the `SELECT * FROM <class>` entries, boundary whitespace or end (trailing `WHERE` allowed). Non-canonical entries (none today) fall back to literal prefix + boundary |
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
Interval gather rules (e.g. a registry poll every 60 s) ran during **all** enrollment phases
and emitted an event on **every** tick — including `exists=false` events while the target key
did not exist yet. A rule needed only from Account Setup onwards produced hundreds of
identical noise events per session and wasted collector runs.
# Schema
Three opt-in fields on `GatherRule` (Shared model, `rules/schema/gather-rule.schema.json`,
delivered to the agent via `GET agent/config`; ConfigVersion 34). Absent fields = legacy
behavior — full back-compat.
| Field | Type | Semantics |
|---|---|---|
| `activePhases` | `string[]` \| null | Rule runs only while the current phase is in the list. Null/empty = unrestricted. |
| `activeFromPhase` | `string` \| null | Sticky latch: activates when the phase first reaches the threshold, then stays active for the rest of the session — including through `Failed`. Null = unrestricted. |
| `emitMode` | `"always"` \| `"on_change"` \| null | `on_change`: poll on the trigger cadence, emit only when the collected result changes. Null/`always` = emit every collection (legacy). |
Canonical phase tokens are the `EnrollmentPhase` enum **names**`Start`(0) …`Complete`(7).
description: How the customer-facing PowerShell scripts reach the download alias, why publishing is decoupled from agent releases, and the guards that keep the published copy, the version badge and the portal's version oracle in agreement.
Four PowerShell scripts are served to customers from `https://download.autopilotmonitor.com/agent/`:
| Blob | Source | Consumed by |
| --- | --- | --- |
| `Install-AutopilotMonitor.ps1` | `scripts/Bootstrap/Install-AutopilotMonitor.ps1` | Intune platform script (customer uploads a copy); downloaded live on every device by the WDP bootstrap MSI |
| `Install-AutopilotMonitor-Dev.ps1` | rendered from the same source | dev fleet |
| `Test-ShouldBootstrapAgent.ps1` | `scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1` | customer dry run via `irm ... \| iex` |
| `Grant-AutopilotMonitorAddOn.ps1` | `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` | optional Graph permission grant |
`Uninstall-AutopilotMonitor.ps1` is deliberately not published — nothing links it, and an
unauthenticated uninstall script on the public download host is not a feature.
The alias is a Front Door route in front of the `agent` container; see
[url-registry.md](url-registry.md) for the host registry and the migration to the alias.
# Publishing chain
`.github/scripts/Publish-BootstrapScripts.ps1` is the single owner. Two workflows call it
with identical arguments:
* **`publish-scripts.yml`** — on every push to `main` touching `scripts/Bootstrap/**`,
`scripts/CustomerSetup/**` or the publisher itself, plus `workflow_dispatch` with a
`dry_run` input.
* **`build-agent.yml`** — inside the `publish_as_stable` branch, after `version.json` is
uploaded, so a release lands a set consistent with what is already live.
Before this split, the script blobs rotated **only** on a stable agent release. A bootstrap
fix therefore sat unpublished until the next agent cutover — while the WDP MSI downloads the
published copy on every enrolling device. Script publishing is now decoupled from the agent
release cadence; the release path still publishes, so it can never fall behind either.
Both workflows share the concurrency group `agent-blob-publish` (`cancel-in-progress: false`).
Both write `version.json`, and the publisher's `bootstrapVersion` read-modify-write must not
interleave with a release writing the agent fields.
# Publisher steps
1. **Parse**`$ScriptVersion` from the bootstrap source.
2. **Dev render** — literal substitution of the two `$AgentDownloadUrl` / `$VersionJsonName`
defaults. A missing anchor literal is a hard failure: a silently un-substituted dev script
would point the dev fleet at the stable agent.
3. **Version-bump guard** — if the published bootstrap changed *behaviour* under an unchanged
`$ScriptVersion`, the run aborts. Comment and formatting changes publish with a warning.
See [Version oracles](#version-oracles).
4. **Upload** every blob with `Cache-Control: no-cache` (they all rotate in place), mirrored
fail-soft to the legacy storage account.
5. **Reconcile the version oracles.**
6. **Verify through the alias** — re-download each blob and compare SHA-256, six attempts
20 s apart. A persistent mismatch means blobs were written but Front Door serves stale
content, and the error names the `az afd endpoint purge` command.
## Deterministic publish form
Every blob is published as **CRLF, UTF-8 without BOM**, normalized by the publisher rather
than taken from the working copy. Git normalizes line endings on checkout — a Windows runner
yields CRLF, a `core.autocrlf=false` clone yields LF — so publishing raw working-copy bytes
would emit different blobs for identical source and make every byte comparison, guard and
verification alike, depend on where the workflow ran. CRLF is also what PS 5.1 expects on the
device and what has always been published.
The ASCII-only rule for `scripts/Bootstrap/*.ps1` (enforced by the gates below) is unrelated
but adjacent: PS 5.1 reads BOM-less files as ANSI and corrupts multi-byte characters.
## Version oracles
Three places state the bootstrap script version, and all three are derived from
`$ScriptVersion` in the source:
* `version.json.bootstrapVersion` — read by the docs badge. Written by the publisher as a
read-modify-write under `If-Match`, so a concurrent agent release keeps its own fields.
`build-agent.yml` seeds the field when it builds the manifest so the published manifest is
never briefly missing it; the publisher owns and re-asserts it afterwards.
* `AdminConfiguration.LatestBootstrapV2ScriptVersion` — read by the portal to tell a customer
their uploaded copy is outdated. **Only** the publisher writes it; `build-agent.yml` writes
the `LatestAgentV2*` fields and nothing else. One writer per field.
* The `Bootstrap script version: v<x>` marker the script logs, parsed out of captured platform
script stdout by `utils/bootstrapVersion.ts`.
The version-bump guard exists because those three would otherwise keep asserting a version
that no longer matches what customers download: a behaviour change under an unchanged version
is invisible to every consumer, and customers decide from that version whether to re-upload
their Intune copy.
"Changed" means changed **behaviour**, matching what the source file's own comment on
`$ScriptVersion` already asks for ("bump on meaningful changes"). The guard compares a code
fingerprint — the PowerShell token stream with comments, newlines and line continuations
removed — so comment fixes, typo corrections in comments and reflow publish with a
`::warning::` instead of failing. Tokenising rather than regex-stripping keeps a `#` inside a
string literal from being mistaken for a comment.
# Gates
`.github/workflows/bootstrap-script-gates.yml` is a `workflow_call` workflow holding the
checks once:
* Pester suite under **Windows PowerShell 5.1** — the runtime IME uses for platform scripts.
* ASCII-only scan over `scripts/Bootstrap/*.ps1`.
* `Publish-BootstrapScripts.ps1 -DryRun` — exercises the dev-render anchors and the
version-bump guard, and reports per blob whether the alias is current, stale or missing.
Two callers: `pester.yml` on pull requests (reports, does not block the merge button), and
`publish-scripts.yml` on pushes to `main`, where the gates job is a `needs:` dependency of the
publish job and therefore **blocks** the upload. `pester.yml` deliberately has no push trigger
— it would duplicate the gate run with no consequence.
# Operating it
* Dry run and drift check, no credentials needed for the read paths:
title: URL Registry — One Place per Component, Guard-Enforced
description: Every well-known own or Microsoft host lives in exactly one registry file per component (Constants.cs, utils/config.ts, src/config.ts); guard tests fail the build on any literal outside them. Includes the agent-download host migration to the download alias.
title: Version Contract — Agent, Backend, MCP, Web
description: One version shape for all shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
@description('Entra ID client secret for OAuth token exchange')
paramentraClientSecretstring=''
@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).')
parammcpAllowedRedirectHostsstring=''
@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).')
parammcpPublicUrlstring=''
@secure()
// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode
// >=). `az bicep build --stdout` writes through the Windows console codec and
// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine.
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
paramoAuthStateSigningKeystring=''
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
paramtoolLoggingEnabledbool=true
// --- Azure Container Registry ---
// Admin user is DISABLED: a registry-wide username/password pair is a static,
// non-rotating credential that would also have to live in a Container App secret.
// The app pulls with its system-assigned identity + AcrPull instead (see below).
"description":"Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
"severity":"warning",
"category":"apps",
"version":"1.2.0",
"version":"1.2.1",
"enabled":true,
"evaluateOn":[
"enrollment_end",
@@ -93,7 +93,7 @@
"steps":[
"Open Event Viewer → Applications and Services Logs → Microsoft → Windows → AppXDeploymentServer → Operational on the device (or in the diagnostics archive if uploaded)",
"Filter for Error-level events around the ESP failure time and confirm the package name and HRESULT match this finding",
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = invalid/corrupt package, 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = package could not be opened (unsigned, publisher mismatch, or not found), 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
"title":"App Install Blocked by Another Installer (0x80070652)",
"description":"Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity":"high",
"category":"apps",
"version":"1.0.0",
"author":"Autopilot Monitor",
"enabled":true,
"baseConfidence":70,
"conditions":[
{
"signal":"mutex_blocked_install",
"source":"event_type",
"eventType":"app_install_failed",
"dataField":"hresultFromWin32",
"operator":"equals",
"value":"-2147023278",
"required":true
},
{
"signal":"installer_exit_1618",
"source":"event_data",
"eventType":"app_install_failed",
"dataField":"exitCode",
"operator":"equals",
"value":"1618",
"required":false
},
{
"signal":"esp_failed_with_mutex_code",
"source":"event_data",
"eventType":"esp_failure_settle_started",
"dataField":"errorCode",
"operator":"equals",
"value":"0x80070652",
"required":false
}
],
"confidenceFactors":[
{
"signal":"installer_exit_1618",
"condition":"exists",
"weight":10
},
{
"signal":"esp_failed_with_mutex_code",
"condition":"exists",
"weight":20
}
],
"confidenceThreshold":65,
"explanation":"App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
"remediation":[
{
"title":"Identify the competing installer",
"steps":[
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
]
},
{
"title":"Serialize the install chain",
"steps":[
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
]
}
],
"relatedDocs":[
{
"title":"Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
"title":"App Return Code Requested a Reboot During Enrollment",
"description":"Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
"severity":"warning",
"category":"apps",
"version":"1.0.0",
"author":"Autopilot Monitor",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"trigger":"single",
"baseConfidence":60,
"conditions":[
{
"signal":"reboot_return_code",
"source":"event_data",
"eventType":"app_install_completed",
"dataField":"exitCodeClass",
"operator":"in",
"value":"SoftReboot,HardReboot",
"required":true
},
{
"signal":"hard_reboot_return_code",
"source":"event_data",
"eventType":"app_install_completed",
"dataField":"exitCodeClass",
"operator":"equals",
"value":"HardReboot",
"required":false
},
{
"signal":"reboot_observed",
"source":"event_count",
"eventType":"system_reboot_detected",
"operator":"count_gte",
"value":"1",
"required":false
}
],
"confidenceFactors":[
{
"signal":"hard_reboot_return_code",
"condition":"exists",
"weight":25
},
{
"signal":"reboot_observed",
"condition":"exists",
"weight":15
}
],
"confidenceThreshold":60,
"explanation":"App **{{appName}}** returned exit code **{{exitCode}}**, which its Intune return-code table maps to **{{exitCodeClass}}** - the installer asked for a restart before its work is complete.\n\n- **SoftReboot**: the Intune Management Extension reports the app as *pending reboot*. The Enrollment Status Page treats it as completed and moves on, and Intune only marks the app installed after the next restart re-evaluates the detection rule. Until then the app may be unusable or half-configured, and the user sees a restart notification.\n- **HardReboot**: the Intune Management Extension stops processing further apps and, in Device Setup, restarts the device right away (the restart grace period does not apply during the ESP) - the same mid-enrollment restart and second sign-in that ANALYZE-ESP-005 attributes to reboot-required policies. In Account Setup the extension does not restart the device itself and the app stays pending.\n\nA restart requested by an installer is normal for many MSI packages (exit codes 3010 and 1641), but during Autopilot it lengthens the ESP, can surprise the user with a restart, and hides the app's real state until after the reboot. The return-code class is the app's own configuration in Intune, not a device fault.",
"remediation":[
{
"title":"Check the app's return-code mapping",
"steps":[
"Open the app in the Intune admin center > Properties > Program and review the return codes table: exit code {{exitCode}} is currently mapped to {{exitCodeClass}}",
"If the installer only recommends a restart (MSI 3010 ERROR_SUCCESS_REBOOT_REQUIRED, 1641 ERROR_SUCCESS_REBOOT_INITIATED), map the code to Soft reboot instead of Hard reboot so the next app can install before the restart",
"If the app works without an immediate restart, suppress the installer's own reboot (for MSI: REBOOT=ReallySuppress on the install command) so it returns 0 and the restart happens on your schedule",
"Keep Hard reboot only when the following apps genuinely cannot install before the restart - and then expect the mid-ESP restart in every enrollment"
]
},
{
"title":"Take the app out of the ESP critical path",
"steps":[
"If the app is not needed for first use, remove it from the ESP blocking-app list so the restart happens after the user reaches the desktop",
"If it must stay blocking, order it last in Device Setup (dependencies or install order) so a hard reboot does not interrupt the remaining apps",
"For Soft reboot apps, plan a restart at the end of enrollment or accept that the app finishes on the user's first restart"
]
}
],
"relatedDocs":[
{
"title":"Microsoft Learn: Add a Win32 app - Program step (device restart behavior and return codes)",
"title":"Windows Update Failed During Enrollment",
"description":"Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
"description":"Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
"title":"System Clock Skew — TPM Attestation and ESP Timeout Risk",
"description":"The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts. Knowledge source: docs/agent/autopilot-ztd-diagnostics.md.",
"description":"The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
"title":"Device Clock Wrong During Enrollment — Jump or Sustained Offset",
"description":"The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
"severity":"warning",
"category":"device",
"version":"1.0.0",
"author":"Autopilot Monitor",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed"
],
"trigger":"single",
"baseConfidence":60,
"confidenceThreshold":50,
"conditions":[
{
"signal":"clock_jump",
"source":"clock_skew",
"skewMetric":"clock_jump",
"operator":"gte",
"value":"300",
"required":false
},
{
"signal":"sustained_offset",
"source":"clock_skew",
"skewMetric":"sustained_offset",
"operator":"gte",
"value":"300",
"required":false
}
],
"confidenceFactors":[],
"explanation":"This device's {{skewSummary}} (five-minute threshold). A clock that is off by five or more minutes breaks time-sensitive enrollment mechanisms: Kerberos tolerates at most 5 minutes of skew (Hybrid Join), Entra ID token validation allows about 5 minutes on token lifetimes, and freshly issued SCEP/PKCS certificates can be rejected as \"not yet valid\" when the clock is more than ~10 minutes behind the CA.\n\nUnlike ANALYZE-DEV-007 (a one-shot NTP comparison at agent startup), this finding is measured across the whole session against the server's receive time — it also catches devices where the NTP check could not run (UDP 123 blocked) or where the clock changed after startup (time sync correcting a wrong RTC, Windows auto-timezone, manual change).",
"remediation":[
{
"title":"Fix the device clock and time source",
"steps":[
"Check the BIOS/RTC clock and the CMOS battery if the wrong time recurs on the same hardware.",
"At OOBE, run 'w32tm /resync /force' (Shift+F10 command prompt) to sync with time.windows.com.",
"Ensure UDP port 123 to time.windows.com is open in the deployment network.",
"Review 'Set time automatically' and 'Set time zone automatically' behavior on the deployment image — automatic timezone changes mid-enrollment show up as clock jumps."
]
}
],
"relatedDocs":[
{
"title":"Microsoft Learn: Windows Autopilot known issues — device provisioning can fail (clock skew)",
"title":"Battery Critically Low During Enrollment",
"description":"Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
"severity":"high",
"category":"device",
"version":"1.0.0",
"enabled":true,
"evaluateOn":[
"enrollment_end",
"whiteglove_sealed",
"on_event:power_state_change"
],
"trigger":"single",
"baseConfidence":90,
"confidenceThreshold":60,
"conditions":[
{
"signal":"battery_threshold_15",
"source":"event_data",
"eventType":"power_state_change",
"dataField":"thresholdPercent",
"operator":"equals",
"value":"15",
"required":true
}
],
"explanation":"The device's battery fell below **{{thresholdPercent}}%** while the enrollment was running on battery power. If the battery drains completely mid-enrollment, the device is left in a half-provisioned state — apps, policies, or the user profile may be incomplete, and in most cases the device has to be reset and re-enrolled. The agent detected the threshold crossing live via a Windows power-status notification; the exact percentage at that moment is recorded on the matched power_state_change event in the session timeline.",
"remediation":[
{
"title":"Secure power for the running enrollment",
"steps":[
"Connect the device to AC power immediately — the enrollment continues normally once power is stable.",
"If the device shut down before completing, check whether the session reached the desktop; if not, reset the device (Autopilot reset or reimage) and re-enroll it plugged in.",
"For technician/White Glove flows, make AC power part of the bench checklist — pre-provisioning on battery is the most common source of this alert.",
"If this fires across many devices of one model, review that model's battery health (the hardware_spec event carries design vs. full-charge capacity)."
]
}
],
"markSessionAsFailedDefault":false,
"tags":[
"device",
"power",
"battery",
"environment"
]
}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.