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>
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>